diff --git a/README.md b/README.md index 6931f20..c9cfe2b 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,13 @@ MCP 서버는 클라이언트가 시작할 때 프로세스로 떠서 실행되 | `get_service(id)` | 특정 서비스의 상세 정보(필요한 보안패키지 전체, 호환성 주의사항, 아이콘 URL)를 반환합니다 | | `list_categories()` | 카테고리별 개수를 반환합니다 | | `list_companions(query?)` | 보조 프로그램(공용 소프트웨어) 목록을 반환합니다 | -| `generate_wsb(serviceIds[])` | 실행용 `.wsb` XML 텍스트를 생성합니다(모든 OS). 지원 러너에서 실행합니다 | -| `launch_sandbox(serviceIds[])` | 즉시 샌드박스를 실행합니다. Windows는 Windows Sandbox, macOS(Apple Silicon)는 [macSandbox](https://github.com/yourtablecloth/macSandbox), 그 외(Linux 등)는 `TABLECLOTH_WSB_RUNNER` 환경변수로 지정한 러너를 씁니다 | +| `check_url(url)` | 어떤 페이지 URL이 보안프로그램이 필요한 사이트인지 판정합니다(실행 없음). 샌드박스로 열지 그냥 브라우저로 열지 고르는 데 씁니다 | +| `generate_wsb(serviceIds?[], targetUrl?)` | 실행용 `.wsb` XML 텍스트를 생성합니다(모든 OS). 지원 러너에서 실행합니다 | +| `launch_sandbox(serviceIds?[], targetUrl?)` | 즉시 샌드박스를 실행합니다. Windows는 Windows Sandbox, macOS(Apple Silicon)는 [macSandbox](https://github.com/yourtablecloth/macSandbox), 그 외(Linux 등)는 `TABLECLOTH_WSB_RUNNER` 환경변수로 지정한 러너를 씁니다 | + +`generate_wsb`와 `launch_sandbox`는 `serviceIds`와 `targetUrl` 중 최소 하나가 필요합니다. +`targetUrl`을 주면 보안프로그램 설치가 끝난 뒤 **그 페이지가 그대로** 열립니다(생략하면 서비스의 대표 URL). +`serviceIds` 없이 URL만 줘도 카탈로그에서 해당 서비스를 자동으로 찾아냅니다. ## 예시 흐름 (연말정산) @@ -104,6 +109,12 @@ MCP 서버는 클라이언트가 시작할 때 프로세스로 떠서 실행되 2. `launch_sandbox(["<홈택스 id>"])`를 호출하면 보안프로그램이 갖춰진 일회용 샌드박스가 뜨고 사이트가 열립니다. 3. 인증서나 간편인증, 연말정산간소화 조회와 발급은 사용자가 직접 진행합니다. +## 예시 흐름 (검색으로 찾은 상품 페이지) + +1. 웹 검색 MCP로 국민은행의 특정 상품 페이지 URL을 찾습니다. +2. `check_url("<그 URL>")`로 보안프로그램이 필요한 사이트인지 판정합니다. +3. 필요하면 `launch_sandbox(targetUrl: "<그 URL>")` — 보안프로그램이 설치된 샌드박스에서 **그 상품 페이지가 그대로** 열리고, 사용자는 거기서 계속 브라우징합니다. 필요 없으면 사용자가 평소 브라우저로 열면 됩니다. + ## TableCloth 리포지터리에 의존하지 않습니다 런타임에 공개된 자산만 소비합니다. diff --git a/SPEC.md b/SPEC.md index ed3ad24..5fc7dc9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,7 +26,8 @@ ## 3. 공유 리소스 (`shared/`) - `shared/strings.json` — 서버 지침, 도구 title/description/파라미터 설명, note/hint, `securityNote`. -- `shared/wsb-template.xml` — `.wsb` 정본 템플릿(치환점 `__SITEIDS__` 1개). +- `shared/wsb-template.xml` — `.wsb` 정본 템플릿(치환점 `__SPORK_SITE_IDS__`, `__SPORK_TARGET_URL__`). + `LogonCommand` 는 상류 릴리스 자산 `no-install-spork-deeplink.wsb` 와 **바이트 일치**를 유지한다. 소비 규칙은 [`shared/README.md`](shared/README.md) 참조. 요약: 문자열은 `shared/` 에서만 수정하고, C# 의 attribute 상수(도구 Description/Title/파라미터 설명)는 런타임 로드가 불가하므로 코드에 두되 @@ -49,8 +50,11 @@ C# 의 attribute 상수(도구 Description/Title/파라미터 설명)는 런타 | `get_service` | RO | `id: string`(필수) | `{id, displayName, displayNameEn?, category, url, iconUrl, packages[]: {name,url,arguments}, edgeExtensions[]: {name,extensionId,crxUrl}, searchKeywords?, compatNotes?}` 또는 `{error, hint}` | | `list_categories` | RO | 없음 | `{totalServices, categories[]: {category, count}}` (count 내림차순) | | `list_companions` | RO | `query?: string` | `{matched, companions[]: {id, displayName, displayNameEn?, url}}` | -| `generate_wsb` | RO | `serviceIds: string[]`(1개+) | `{siteIds[], unknownIdsIgnored?[], wsb, usage, securityNote}` 또는 `{error, unknownIds[], hint}` | -| `launch_sandbox` | RW, Dx=false | `serviceIds: string[]`(1개+) | `{launched:true, runner, siteIds[], unknownIdsIgnored?[], wsbPath, note, securityNote}` 또는 `{launched:false, error, hint, unknownIds?[]}` | +| `check_url` | RO | `url: string`(필수) | `{url, host, sandboxSupported, serviceId?, displayName?, category?, serviceUrl?, requiredPackages?[], candidates?[], reason?, note?, error?, hint?}` | +| `generate_wsb` | RO | `serviceIds?: string[]`, `targetUrl?: string` (**둘 중 최소 하나**) | `{siteIds[], unknownIdsIgnored?[], targetUrl?, resolvedFromUrl?, targetUrlIgnored?, wsb, usage, targetUrlNote?, securityNote}` 또는 `{error, unknownIds?[], candidates?[], hint}` | +| `launch_sandbox` | RW, Dx=false | `serviceIds?: string[]`, `targetUrl?: string` (**둘 중 최소 하나**) | `{launched:true, runner, siteIds[], unknownIdsIgnored?[], targetUrl?, resolvedFromUrl?, targetUrlIgnored?, wsbPath, note, targetUrlNote?, securityNote}` 또는 `{launched:false, error, hint, unknownIds?[], candidates?[]}` | + +오류 응답의 `unknownIds` 는 **비어 있으면 생략**한다(양 구현 동일). 검색 매칭 규칙: `query` 를 공백/쉼표/탭/개행으로 토큰화(소문자, 중복 제거) → 각 서비스의 검색 대상 텍스트(표시명 한/영 + URL + 보안패키지명 + 검색키워드)에 포함되는 토큰 수를 점수로 @@ -68,13 +72,58 @@ id 문자셋 방어: `.wsb` 주입 전 `^[A-Za-z0-9._-]+$` 만 허용. ## 7. `.wsb` 생성 계약 -- 템플릿: `shared/wsb-template.xml` 로드 후 `__SITEIDS__` 를 사이트 사전선택 구문으로 치환. -- 사이트 주입 채널: 환경변수 `TABLECLOTH_SITE_IDS`(PARAMETERIZED_WSB_SPEC.md §0.5). 치환 구문 예: - ` $env:TABLECLOTH_SITE_IDS = '' '';` (id 없으면 빈 문자열). +- 템플릿: `shared/wsb-template.xml` 로드 후 치환점 2개를 **값 자리에만** 채운다. + 주입 채널은 환경변수 2개이며 서로 독립이다(PARAMETERIZED_WSB_SPEC.md §3.3). + + | 치환점 | 환경변수 | 값 | + | --- | --- | --- | + | `__SPORK_SITE_IDS__` | `TABLECLOTH_SITE_IDS` | 공백으로 이은 카탈로그 id. 없으면 빈 문자열 | + | `__SPORK_TARGET_URL__` | `TABLECLOTH_TARGET_URL` | 설치 후 열 페이지 URL. 없으면 빈 문자열 | + +- **`tablecloth:` URI 스킴 딥링크는 생성하지 않는다.** 그 핸들러는 Windows TableCloth 앱만 등록해 + macOS/Linux 에서 GA 가 아니다. 이 서버의 산출물은 항상 `.wsb` 파일이며, 이 파일을 소비하는 게스트는 + (Windows Sandbox 든 macSandbox 든) 항상 Windows 라 위 두 채널은 모든 호스트에서 동일하게 동작한다. +- id 는 `^[A-Za-z0-9._-]+$` 화이트리스트를 통과했으므로 이스케이프하지 않는다. - 실행 자산은 전부 GitHub 릴리스 공개 URL(`tablecloth-prepare.ps1` 등). 무설치 Express 레인. - `securityNote`: 응답에 항상 포함. 문구는 `strings.json` 의 `sandbox.securityNote`. - 알려진 이슈/하드닝: 명령이 원격 스크립트 실행 형태라 오탐될 수 있음 → [#1](https://github.com/yourtablecloth/TableClothMcp/issues/1). +### 7.1 `targetUrl` 채널 계약 + +검색 MCP 등이 찾아 준 **딥 URL 을 그대로** 샌드박스에서 열기 위한 채널. 두 구현이 같은 입력에 +**같은 바이트**를 내야 한다(conformance 가 강제). + +**검증**(생산자 의무, PARAMETERIZED_WSB_SPEC §3.3) — 실패 시 `reason` 을 오류 문구에 넣어 거부: + +- `http://` 또는 `https://` 로 시작하는 절대 URL (`notHttp`) +- 자격증명(`user@host`) 없음 (`credentials`) +- 2048자 이하 (`tooLong`), 공백·제어문자 없음 (`badChars`), 호스트 존재 (`noHost`) + +**이스케이프**(두 층, 순서 고정) — 두 층이 건드리는 문자 집합은 겹치지 않아 순서는 결과에 무관하지만 +구현 간 동일성을 위해 고정한다: + +1. PowerShell/argv 층: `'` → `%27`, `"` → `%22`, **그리고 비ASCII(> U+007E) 전부 UTF-8 퍼센트 인코딩.** + 정본 `.wsb` 가 ASCII only 를 요구한다(게스트 코드페이지가 호스트 언어팩마다 달라 한글이 깨지고, + macSandbox 는 명령을 `.cmd` 파일로 한 번 더 경유시킨다). +2. XML 층: `&` `<` `>` → 엔티티. 실제 은행 URL 의 쿼리스트링에 `&` 가 흔해 필수. + +URL 은 Base64 등으로 감추지 않고 **평문**으로 둔다 — 실행 전에 어떤 주소가 열리는지 눈으로 확인할 수 +있어야 한다는 정본의 신뢰 모델을 그대로 따른다. + +**해석**(호스트측, 카탈로그 기준. PARAMETERIZED_WSB_SPEC §6.5 의 호스트측 대응): + +- 등록 도메인은 **퍼블릭 서픽스를 인식해** 계산한다. 한국 2단계 서픽스(`co.kr`, `or.kr`, `go.kr` 등)를 + 처리하지 않으면 `co.kr` 전체가 한 덩어리가 되므로 필수다. +- `serviceIds` 가 함께 오면: **모든 id 가 URL 과 같은 등록 도메인**이어야 URL 이 살아남는다(§6.5-3). + 어긋나면 게스트도 URL 만 버리므로, 호스트가 먼저 버리고 `targetUrlIgnored` 로 사실대로 알린다 + (id 채널은 그대로 동작 — 오류가 아니다). +- `targetUrl` 만 오면: 같은 등록 도메인 후보 중 **호스트 라벨이 가장 많이 일치**하는 하나로 해석한다. + 유일하면 그 id 를 `siteIds` 에 넣고 `resolvedFromUrl` 로 알린다. + **동점이면 추측하지 않고** `candidates` 를 돌려준다 — 게스트는 조용히 카탈로그 선순위를 택할 수밖에 + 없지만, 호스트에는 되물을 수 있는 모델이 있고 잘못 고르면 엉뚱한 보안프로그램이 설치된다. +- 카탈로그 도메인 밖이면 거부한다. 게스트가 어차피 URL 을 버리고 카탈로그 UI 를 띄우므로, 조용한 + 거짓 성공을 만들지 않으려면 호스트가 먼저 막아야 한다. + ## 8. 샌드박스 실행 계약 (`launch_sandbox`) 러너 결정 순서: @@ -108,6 +157,9 @@ id 문자셋 방어: `.wsb` 주입 전 `^[A-Za-z0-9._-]+$` 만 허용. 2. 대표 입력의 `tools/call` 출력 JSON 동일: - `search_services("은행")`, `list_categories()`, `get_service("Hometax")`, `generate_wsb(["Hometax"])` - `generate_wsb` 의 `wsb` 는 `shared/wsb-template.xml` + 주입 구문과 바이트 일치(개행 정규화 허용). + - `targetUrl` 채널: 딥 URL(쿼리스트링 `&`), URL-only 자동 판별, 동점, 카탈로그 밖 도메인, + id/URL 도메인 불일치, 형식 위반, 비ASCII 경로 — 7개 케이스의 응답이 두 구현에서 동일하고 + 생성된 `.wsb` 가 ASCII 를 유지하는지 검증한다. 3. `securityNote` 등 note/hint 가 `strings.json` 과 일치. 카탈로그는 라이브 데이터라, 동일성 비교는 **같은 시점 스냅샷**을 두 구현에 주입하거나 필드 구조/불변 항목 diff --git a/SharedResources.cs b/SharedResources.cs index c983859..725b500 100644 --- a/SharedResources.cs +++ b/SharedResources.cs @@ -18,6 +18,20 @@ internal static class SharedResources public static readonly string WsbTemplate; // sandbox 공용 public static readonly string SecurityNote; + public static readonly string TargetUrlNote; + public static readonly string ErrorNoTarget; + public static readonly string HintNoTarget; + public static readonly string TargetUrlInvalidError; // {reason} + public static readonly string TargetUrlInvalidHint; + public static readonly string TargetUrlNoMatchError; // {host} + public static readonly string TargetUrlNoMatchHint; + public static readonly string TargetUrlAmbiguousError; // {host} + public static readonly string TargetUrlAmbiguousHint; // {candidates} + public static readonly string TargetUrlIdMismatchError; // {host} + public static readonly string TargetUrlIdMismatchHint; + // check_url + public static readonly string CheckUrlNoteSupported; + public static readonly string CheckUrlNoteUnsupported; // search_services public static readonly string SearchResultNote; // get_service @@ -47,7 +61,24 @@ static SharedResources() var tools = root.GetProperty("tools"); ServerInstructions = Str(root.GetProperty("server"), "instructions"); - SecurityNote = Str(root.GetProperty("sandbox"), "securityNote"); + + var sb = root.GetProperty("sandbox"); + SecurityNote = Str(sb, "securityNote"); + TargetUrlNote = Str(sb, "targetUrlNote"); + ErrorNoTarget = Str(sb, "errorNoTarget"); + HintNoTarget = Str(sb, "hintNoTarget"); + TargetUrlInvalidError = Str(sb, "targetUrlInvalidError"); + TargetUrlInvalidHint = Str(sb, "targetUrlInvalidHint"); + TargetUrlNoMatchError = Str(sb, "targetUrlNoMatchError"); + TargetUrlNoMatchHint = Str(sb, "targetUrlNoMatchHint"); + TargetUrlAmbiguousError = Str(sb, "targetUrlAmbiguousError"); + TargetUrlAmbiguousHint = Str(sb, "targetUrlAmbiguousHint"); + TargetUrlIdMismatchError = Str(sb, "targetUrlIdMismatchError"); + TargetUrlIdMismatchHint = Str(sb, "targetUrlIdMismatchHint"); + + var cu = tools.GetProperty("check_url"); + CheckUrlNoteSupported = Str(cu, "noteSupported"); + CheckUrlNoteUnsupported = Str(cu, "noteUnsupported"); var ss = tools.GetProperty("search_services"); SearchResultNote = Str(ss, "resultNote"); diff --git a/Tools/AppJsonContext.cs b/Tools/AppJsonContext.cs index b1440b9..f794777 100644 --- a/Tools/AppJsonContext.cs +++ b/Tools/AppJsonContext.cs @@ -13,4 +13,5 @@ namespace TableCloth.Mcp.Tools; [JsonSerializable(typeof(CompanionsResponse))] [JsonSerializable(typeof(WsbResponse))] [JsonSerializable(typeof(LaunchResponse))] +[JsonSerializable(typeof(CheckUrlResponse))] internal sealed partial class AppJsonContext : JsonSerializerContext; diff --git a/Tools/CatalogTools.cs b/Tools/CatalogTools.cs index a5bfea7..453a853 100644 --- a/Tools/CatalogTools.cs +++ b/Tools/CatalogTools.cs @@ -14,6 +14,79 @@ public sealed class CatalogTools { private static readonly char[] Separators = { ' ', ',', '\t', '\n', '\r' }; + [McpServerTool(Name = "check_url", Title = "URL 샌드박스 필요 여부 판정", ReadOnly = true, OpenWorld = true)] + [Description( + "웹 검색 등으로 얻은 페이지 URL 이 보안프로그램(공동인증서, 키보드보안 등)이 필요한 한국 공공/금융 " + + "사이트인지 판정한다. 아무것도 실행하지 않고 판정 정보만 돌려준다.\n\n" + + "이럴 때 사용하라: 대화 중 특정 은행/공공기관의 상품·안내 페이지 URL 을 확보했고, 사용자가 그 페이지로 " + + "접속하려 할 때. 이 도구로 먼저 판정한 뒤, 샌드박스가 필요하면 launch_sandbox 에 그 URL 을 targetUrl 로 " + + "넘겨 열고, 필요 없으면 사용자가 평소 브라우저로 열도록 안내한다.\n\n" + + "이 서버는 호스트(사용자 PC)의 브라우저를 직접 열지 않는다. 판정과 샌드박스 실행만 담당한다.")] + public static async Task CheckUrl( + CatalogClient catalog, + [Description("판정할 페이지의 전체 URL(http/https 절대 URL).")] string url, + CancellationToken ct = default) + { + if (!TargetUrl.TryValidate(url, out var normalized, out var host, out var reason)) + { + return new CheckUrlResponse + { + Url = (url ?? string.Empty).Trim(), + SandboxSupported = false, + Reason = "invalid", + Error = SharedResources.TargetUrlInvalidError.Replace("{reason}", TargetUrl.ReasonText(reason)), + Hint = SharedResources.TargetUrlInvalidHint, + }; + } + + var doc = await catalog.GetAsync(ct: ct).ConfigureAwait(false); + var r = TargetUrl.Resolve(doc.Services, host); + + if (r.Kind == TargetUrl.ResolutionKind.NoMatch) + { + return new CheckUrlResponse + { + Url = normalized, + Host = host, + SandboxSupported = false, + Reason = "noMatch", + Note = SharedResources.CheckUrlNoteUnsupported, + Hint = SharedResources.TargetUrlNoMatchHint, + }; + } + + if (r.Kind == TargetUrl.ResolutionKind.Ambiguous) + { + var candidates = r.Candidates + .Select(c => new UrlCandidateDto { Id = c.Id, DisplayName = c.DisplayName, Url = c.Url }) + .ToList(); + return new CheckUrlResponse + { + Url = normalized, + Host = host, + SandboxSupported = false, + Reason = "ambiguous", + Candidates = candidates, + Error = SharedResources.TargetUrlAmbiguousError.Replace("{host}", host), + Hint = SharedResources.TargetUrlAmbiguousHint.Replace("{candidates}", string.Join(", ", candidates.Select(c => c.Id))), + }; + } + + var svc = doc.Services.First(s => string.Equals(s.Id, r.Id, StringComparison.Ordinal)); + return new CheckUrlResponse + { + Url = normalized, + Host = host, + SandboxSupported = true, + ServiceId = svc.Id, + DisplayName = svc.DisplayName, + Category = svc.Category, + ServiceUrl = svc.Url, + RequiredPackages = svc.Packages.Select(p => p.Name).ToArray(), + Note = SharedResources.CheckUrlNoteSupported, + }; + } + [McpServerTool(Name = "search_services", Title = "한국 공공/금융 사이트 검색", ReadOnly = true, OpenWorld = true)] [Description( "한국의 은행, 금융, 공공(e-Gov), 정부 사이트를 사용자가 실제로 이용하려 할 때 알맞은 공식 사이트(카탈로그 " + diff --git a/Tools/SandboxTools.cs b/Tools/SandboxTools.cs index dc3c4ff..0014d21 100644 --- a/Tools/SandboxTools.cs +++ b/Tools/SandboxTools.cs @@ -25,32 +25,39 @@ public sealed partial class SandboxTools [McpServerTool(Name = "generate_wsb", Title = "샌드박스 설정(.wsb) 생성", ReadOnly = true, OpenWorld = true)] [Description( - "선택한 service id 들로 실행할 Windows Sandbox 설정(.wsb) XML 텍스트를 생성해 반환한다(파일 실행은 안 함). " + + "선택한 service id 들(또는 페이지 URL)로 실행할 Windows Sandbox 설정(.wsb) XML 텍스트를 생성해 " + + "반환한다(파일 실행은 안 함). " + "모든 OS 에서 호출 가능 — 사용자에게 .wsb 를 건네 더블클릭하게 할 때 쓴다. " + "생성된 .wsb 는 GitHub 릴리스의 공개 자산만 받아 동작하며, 지정한 사이트들의 보안프로그램을 " + "샌드박스 안에서 자동 설치한 뒤 사이트를 연다. 로그인/인증/업무는 사용자 몫.")] public static async Task GenerateWsb( CatalogClient catalog, - [Description("샌드박스에서 열 카탈로그 service id 목록(1개 이상). search_services 로 확인.")] string[] serviceIds, + [Description("샌드박스에서 열 카탈로그 service id 목록. search_services 로 확인. targetUrl 을 주면 생략할 수 있다.")] string[]? serviceIds = null, + [Description("샌드박스 안에서 열 정확한 페이지 URL(선택). 검색으로 찾은 딥 URL 을 그대로 주면 보안프로그램 설치 후 그 페이지가 열린다. 생략하면 서비스의 대표 URL 이 열린다. serviceIds 없이 이것만 줘도 카탈로그에서 해당 서비스를 자동 판별한다.")] string? targetUrl = null, CancellationToken ct = default) { - var (valid, unknown) = await ResolveIdsAsync(catalog, serviceIds, ct).ConfigureAwait(false); - if (valid.Count == 0) + var plan = await PlanTargetAsync(catalog, serviceIds, targetUrl, ct).ConfigureAwait(false); + if (plan.Error is not null) { return new WsbResponse { - Error = SharedResources.GenerateWsbErrorNoValidIds, - UnknownIds = unknown, - Hint = SharedResources.GenerateWsbHintNoValidIds, + Error = plan.Error, + UnknownIds = plan.Unknown.Count > 0 ? plan.Unknown : null, + Candidates = plan.Candidates, + Hint = plan.Hint, }; } return new WsbResponse { - SiteIds = valid, - UnknownIdsIgnored = unknown.Count > 0 ? unknown : null, - Wsb = BuildWsb(valid), + SiteIds = plan.Ids, + UnknownIdsIgnored = plan.Unknown.Count > 0 ? plan.Unknown : null, + TargetUrl = plan.Url.Length > 0 ? plan.Url : null, + ResolvedFromUrl = plan.ResolvedFromUrl, + TargetUrlIgnored = plan.TargetUrlIgnored, + Wsb = BuildWsb(plan.Ids, plan.Url), Usage = SharedResources.GenerateWsbUsage, + TargetUrlNote = plan.Url.Length > 0 ? SharedResources.TargetUrlNote : null, SecurityNote = SharedResources.SecurityNote, }; } @@ -70,12 +77,16 @@ public static async Task GenerateWsb( "실행한다. 로그인, 인증(공동/금융/간편인증), 실제 신청은 사용자가 직접 한다(RPA 아님).")] public static async Task LaunchSandbox( CatalogClient catalog, - [Description("열 카탈로그 service id 목록(1개 이상). 여러 개면 한 샌드박스에 병합 설치된다.")] string[] serviceIds, + [Description("열 카탈로그 service id 목록. 여러 개면 한 샌드박스에 병합 설치된다. targetUrl 을 주면 생략할 수 있다.")] string[]? serviceIds = null, + [Description("샌드박스 안에서 열 정확한 페이지 URL(선택). 검색으로 찾은 딥 URL 을 그대로 주면 보안프로그램 설치 후 그 페이지가 열린다. 생략하면 서비스의 대표 URL 이 열린다. serviceIds 없이 이것만 줘도 카탈로그에서 해당 서비스를 자동 판별한다.")] string? targetUrl = null, CancellationToken ct = default) { - var (valid, unknown) = await ResolveIdsAsync(catalog, serviceIds, ct).ConfigureAwait(false); - if (valid.Count == 0) - return new LaunchResponse { Launched = false, Error = SharedResources.LaunchErrorNoValidIds, UnknownIds = unknown, Hint = SharedResources.LaunchHintNoValidIds }; + var plan = await PlanTargetAsync(catalog, serviceIds, targetUrl, ct).ConfigureAwait(false); + if (plan.Error is not null) + return new LaunchResponse { Launched = false, Error = plan.Error, UnknownIds = plan.Unknown.Count > 0 ? plan.Unknown : null, Candidates = plan.Candidates, Hint = plan.Hint }; + + var valid = plan.Ids; + var unknown = plan.Unknown; // 러너 선택 순서: (1) TABLECLOTH_WSB_RUNNER 환경변수 오버라이드(모든 OS. Linux 처럼 기본 러너가 // 없는 환경이나 사용자 지정 러너용), (2) OS 기본값. 같은 .wsb 를 Windows Sandbox / macSandbox / @@ -128,7 +139,7 @@ public static async Task LaunchSandbox( }; } - var wsb = BuildWsb(valid); + var wsb = BuildWsb(valid, plan.Url); var path = Path.Combine(Path.GetTempPath(), $"tablecloth-{Guid.NewGuid():n}.wsb"); await File.WriteAllTextAsync(path, wsb, ct).ConfigureAwait(false); @@ -146,8 +157,12 @@ public static async Task LaunchSandbox( Runner = runner, SiteIds = valid, UnknownIdsIgnored = unknown.Count > 0 ? unknown : null, + TargetUrl = plan.Url.Length > 0 ? plan.Url : null, + ResolvedFromUrl = plan.ResolvedFromUrl, + TargetUrlIgnored = plan.TargetUrlIgnored, WsbPath = path, Note = SharedResources.LaunchNoteTemplate.Replace("{runner}", runner), + TargetUrlNote = plan.Url.Length > 0 ? SharedResources.TargetUrlNote : null, SecurityNote = SharedResources.SecurityNote, }; } @@ -258,16 +273,115 @@ public static async Task LaunchSandbox( return (valid, unknown); } - private static string BuildWsb(IReadOnlyList validIds) + private static string BuildWsb(IReadOnlyList validIds, string targetUrl) { - // 템플릿(LogonCommand 원문)은 shared/wsb-template.xml 이 정본이며 본 리포의 no-install-spork.wsb 와 동일. - // 유일한 주입점은 TABLECLOTH_SITE_IDS(사이트 사전선택). __SITEIDS__ 만 치환한다. - // valid 는 이미 SafeIdRegex 통과 → 공백 join 안전(작은따옴표/XML 특수문자 없음). - // .wsb 안에서는 PowerShell 작은따옴표 문자열이라 리터럴 ' 는 '' 로 이스케이프된다. - var idsStmt = validIds.Count == 0 - ? string.Empty - : " $env:TABLECLOTH_SITE_IDS = ''" + string.Join(' ', validIds) + "'';"; + // 템플릿(LogonCommand 원문)은 shared/wsb-template.xml 이 정본이며 상류 no-install-spork-deeplink.wsb 와 + // 바이트 일치. 채널 2개는 값 자리에만 들어간다. 미지정이면 빈 문자열(게스트가 "미지정"으로 취급). + // valid 는 이미 SafeIdRegex 통과 → 공백 join 안전(따옴표/XML 특수문자 없음). URL 만 두 층 이스케이프. + return SharedResources.WsbTemplate + .Replace("__SPORK_SITE_IDS__", string.Join(' ', validIds)) + .Replace("__SPORK_TARGET_URL__", targetUrl.Length == 0 ? string.Empty : TargetUrl.EscapeForWsb(targetUrl)); + } + + // serviceIds 와 targetUrl 을 실제로 .wsb 에 실을 형태로 정리한 결과(Node 의 planTarget 과 동일 규칙). + private readonly record struct TargetPlan + { + public IReadOnlyList Ids { get; init; } + public string Url { get; init; } + public IReadOnlyList Unknown { get; init; } + public ResolvedFromUrlDto? ResolvedFromUrl { get; init; } + public TargetUrlIgnoredDto? TargetUrlIgnored { get; init; } + public string? Error { get; init; } + public string? Hint { get; init; } + public IReadOnlyList? Candidates { get; init; } + } + + private static async Task PlanTargetAsync( + CatalogClient catalog, string[]? serviceIds, string? targetUrl, CancellationToken ct) + { + var (valid, unknown) = await ResolveIdsAsync(catalog, serviceIds ?? [], ct).ConfigureAwait(false); + var raw = (targetUrl ?? string.Empty).Trim(); + + if (raw.Length == 0) + { + if (valid.Count == 0) + return new TargetPlan { Ids = valid, Url = string.Empty, Unknown = unknown, Error = SharedResources.ErrorNoTarget, Hint = SharedResources.HintNoTarget }; + return new TargetPlan { Ids = valid, Url = string.Empty, Unknown = unknown }; + } - return SharedResources.WsbTemplate.Replace("__SITEIDS__", idsStmt); + if (!TargetUrl.TryValidate(raw, out var url, out var host, out var reason)) + { + return new TargetPlan + { + Ids = valid, + Url = string.Empty, + Unknown = unknown, + Error = SharedResources.TargetUrlInvalidError.Replace("{reason}", TargetUrl.ReasonText(reason)), + Hint = SharedResources.TargetUrlInvalidHint, + }; + } + + var doc = await catalog.GetAsync(ct: ct).ConfigureAwait(false); + + // id 가 함께 왔으면 게스트 규칙(§6.5-3)과 같게 판단한다: 모든 id 가 URL 과 같은 등록 도메인이어야 + // URL 이 살아남는다. 어긋나면 게스트도 URL 만 버리므로, 여기서 미리 버리고 사실대로 알린다. + if (valid.Count > 0) + { + var rd = TargetUrl.RegisteredDomain(host); + var coherent = valid.All(id => + { + var svc = doc.Services.FirstOrDefault(s => string.Equals(s.Id, id, StringComparison.Ordinal)); + var h = svc is null ? string.Empty : TargetUrl.HostOf(svc.Url); + return h.Length > 0 && TargetUrl.RegisteredDomain(h) == rd; + }); + + return coherent + ? new TargetPlan { Ids = valid, Url = url, Unknown = unknown } + : new TargetPlan + { + Ids = valid, + Url = string.Empty, + Unknown = unknown, + TargetUrlIgnored = new TargetUrlIgnoredDto( + url, + SharedResources.TargetUrlIdMismatchError.Replace("{host}", host), + SharedResources.TargetUrlIdMismatchHint), + }; + } + + // URL 만 온 경우 — 카탈로그로 서비스를 특정한다. + var r = TargetUrl.Resolve(doc.Services, host); + if (r.Kind == TargetUrl.ResolutionKind.NoMatch) + { + return new TargetPlan + { + Ids = valid, + Url = string.Empty, + Unknown = unknown, + Error = SharedResources.TargetUrlNoMatchError.Replace("{host}", host), + Hint = SharedResources.TargetUrlNoMatchHint, + }; + } + if (r.Kind == TargetUrl.ResolutionKind.Ambiguous) + { + var candidates = r.Candidates.Select(c => new UrlCandidateDto { Id = c.Id, DisplayName = c.DisplayName, Url = c.Url }).ToList(); + return new TargetPlan + { + Ids = valid, + Url = string.Empty, + Unknown = unknown, + Error = SharedResources.TargetUrlAmbiguousError.Replace("{host}", host), + Hint = SharedResources.TargetUrlAmbiguousHint.Replace("{candidates}", string.Join(", ", candidates.Select(c => c.Id))), + Candidates = candidates, + }; + } + + return new TargetPlan + { + Ids = new List { r.Id! }, + Url = url, + Unknown = unknown, + ResolvedFromUrl = new ResolvedFromUrlDto(url, r.Id!), + }; } } diff --git a/Tools/TargetUrl.cs b/Tools/TargetUrl.cs new file mode 100644 index 0000000..a720782 --- /dev/null +++ b/Tools/TargetUrl.cs @@ -0,0 +1,164 @@ +using System.Text; +using TableCloth.Mcp.Catalog; + +namespace TableCloth.Mcp.Tools; + +/// +/// targetUrl 채널(PARAMETERIZED_WSB_SPEC.md §3.3/§6.5) 구현. Node 의 target-url.ts 와 규칙이 +/// 동일해야 한다 — 같은 입력에 같은 바이트를 내야 conformance 가 통과한다(SPEC.md §7.1). +/// +/// 설계 메모: URL 을 로 파싱해 재조립하지 않는다. .NET 의 Uri 와 Node 의 URL 은 +/// 정규화 규칙(트레일링 슬래시, 퍼센트 인코딩 대소문자, IDN)이 서로 달라 두 구현이 갈라진다. +/// 검증은 문자열 수준에서 직접 하고, .wsb 에는 원문을 그대로(이스케이프만 해서) 싣는다. +/// +internal static class TargetUrl +{ + public const int MaxLength = 2048; + + // 한국 2단계 퍼블릭 서픽스. 이 처리가 없으면 "마지막 두 라벨"이 등록 도메인이 되어 + // co.kr 전체(카탈로그 기준 95개)가 한 덩어리로 묶인다 — 선택이 아니라 필수다. + private static readonly HashSet KrSecondLevel = new(StringComparer.Ordinal) + { + "co.kr", "or.kr", "go.kr", "ne.kr", "re.kr", "pe.kr", + "ac.kr", "ms.kr", "hs.kr", "es.kr", "sc.kr", "kg.kr", + }; + + public enum RejectReason { None, Empty, TooLong, BadChars, NotHttp, Credentials, NoHost } + + // Node 의 reason 문자열과 철자까지 같아야 한다(오류 문구에 그대로 박힌다). + public static string ReasonText(RejectReason r) => r switch + { + RejectReason.Empty => "empty", + RejectReason.TooLong => "tooLong", + RejectReason.BadChars => "badChars", + RejectReason.NotHttp => "notHttp", + RejectReason.Credentials => "credentials", + RejectReason.NoHost => "noHost", + _ => "unknown", + }; + + /// 생산자 측 검증(§3.3): http/https 절대 URL, 자격증명 없음, 2048자 이하, 공백/제어문자 없음. + public static bool TryValidate(string? raw, out string url, out string host, out RejectReason reason) + { + url = (raw ?? string.Empty).Trim(); + host = string.Empty; + + if (url.Length == 0) { reason = RejectReason.Empty; return false; } + if (url.Length > MaxLength) { reason = RejectReason.TooLong; return false; } + foreach (var rune in url.EnumerateRunes()) + { + if (rune.Value <= 0x20 || rune.Value == 0x7F) { reason = RejectReason.BadChars; return false; } + } + + var scheme = url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ? 7 + : url.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? 8 + : -1; + if (scheme < 0) { reason = RejectReason.NotHttp; return false; } + + var rest = url[scheme..]; + var end = rest.IndexOfAny(['/', '?', '#']); + var authority = end < 0 ? rest : rest[..end]; + if (authority.Contains('@')) { reason = RejectReason.Credentials; return false; } + + host = StripPort(authority).ToLowerInvariant(); + if (host.Length == 0) { reason = RejectReason.NoHost; return false; } + + reason = RejectReason.None; + return true; + } + + private static string StripPort(string authority) + { + if (authority.StartsWith('[')) + { + var close = authority.IndexOf(']'); // IPv6 리터럴 + return close < 0 ? authority : authority[..(close + 1)]; + } + var colon = authority.IndexOf(':'); + return colon < 0 ? authority : authority[..colon]; + } + + /// 카탈로그 서비스 URL 에서 호스트만 뽑는다(검증 실패 시 빈 문자열). + public static string HostOf(string url) => + TryValidate(url, out _, out var host, out _) ? host : string.Empty; + + /// 퍼블릭 서픽스를 인식한 등록 도메인(§6.5-2). + public static string RegisteredDomain(string host) + { + var labels = host.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (labels.Length <= 2) return string.Join('.', labels); + var last2 = $"{labels[^2]}.{labels[^1]}"; + return KrSecondLevel.Contains(last2) + ? string.Join('.', labels[^3..]) + : last2; + } + + /// + /// .wsb 주입용 이스케이프. 두 층을 순서대로 거친다(§3.3). + /// 1) PowerShell/argv 층: 중첩 따옴표를 깨는 ' 와 " 를 퍼센트 인코딩. + /// 덤으로 비ASCII 도 퍼센트 인코딩한다 — 정본 .wsb 가 "ASCII only"를 요구하고(게스트 코드페이지가 + /// 호스트 언어팩마다 달라 한글이 깨진다), macSandbox 는 명령을 .cmd 파일로 한 번 더 경유시킨다. + /// 2) XML 층: & < > 를 엔티티로. 실제 은행 URL 의 쿼리스트링에 & 가 흔해 필수다. + /// 두 층이 건드리는 문자 집합은 겹치지 않아 순서 자체는 결과에 영향이 없지만, 구현 간 동일성을 + /// 위해 순서를 고정한다. + /// + public static string EscapeForWsb(string value) + { + var sb = new StringBuilder(value.Length); + foreach (var rune in value.EnumerateRunes()) + { + if (rune.Value == '\'' || rune.Value == '"' || rune.Value > 0x7E) PercentEncode(sb, rune); + else sb.Append((char)rune.Value); + } + return sb.ToString().Replace("&", "&").Replace("<", "<").Replace(">", ">"); + } + + private static void PercentEncode(StringBuilder sb, Rune rune) + { + Span buf = stackalloc byte[4]; + var n = rune.EncodeToUtf8(buf); + for (var i = 0; i < n; i++) sb.Append('%').Append(buf[i].ToString("X2")); + } + + public enum ResolutionKind { Match, Ambiguous, NoMatch } + + public readonly record struct Resolution( + ResolutionKind Kind, + string? Id, + IReadOnlyList Candidates); + + /// + /// URL 하나를 카탈로그 서비스로 해석한다(§6.5-4 의 호스트측 대응). + /// 등록 도메인이 같은 후보 중 호스트 라벨이 가장 많이 일치하는 하나를 고른다. + /// 게스트는 동점일 때 조용히 카탈로그 선순위를 택하지만, 여기서는 추측하지 않고 후보를 돌려준다 + /// — 호스트에는 되물을 수 있는 모델이 있고, 잘못 고르면 엉뚱한 보안프로그램이 설치되기 때문. + /// + public static Resolution Resolve(IReadOnlyList services, string host) + { + var rd = RegisteredDomain(host); + if (rd.Length == 0) return new Resolution(ResolutionKind.NoMatch, null, []); + + var pool = services + .Select(s => (svc: s, host: HostOf(s.Url))) + .Where(x => x.host.Length > 0 && RegisteredDomain(x.host) == rd) + .ToList(); + if (pool.Count == 0) return new Resolution(ResolutionKind.NoMatch, null, []); + + var target = host.Split('.', StringSplitOptions.RemoveEmptyEntries); + var scored = pool.Select(x => (x.svc, score: LabelMatch(x.host, target))).ToList(); + var best = scored.Max(x => x.score); + var top = scored.Where(x => x.score == best).Select(x => x.svc).ToList(); + + return top.Count == 1 + ? new Resolution(ResolutionKind.Match, top[0].Id, []) + : new Resolution(ResolutionKind.Ambiguous, null, top); + } + + private static int LabelMatch(string svcHost, string[] target) + { + var labels = svcHost.Split('.', StringSplitOptions.RemoveEmptyEntries); + var n = 0; + while (n < labels.Length && n < target.Length && labels[^(n + 1)] == target[^(n + 1)]) n++; + return n; + } +} diff --git a/Tools/ToolModels.cs b/Tools/ToolModels.cs index 5821fd9..8c8c434 100644 --- a/Tools/ToolModels.cs +++ b/Tools/ToolModels.cs @@ -66,16 +66,34 @@ public sealed record CompanionsResponse public required IReadOnlyList Companions { get; init; } } +// targetUrl 이 카탈로그 서비스로 해석됐음을 알린다(어떤 URL 이 어떤 서비스가 됐는지 투명하게). +public sealed record ResolvedFromUrlDto(string Url, string ServiceId); + +// targetUrl 이 게스트 규칙(§6.5-3)에 걸려 버려졌음을 알린다. id 채널은 그대로 살아 있다. +public sealed record TargetUrlIgnoredDto(string Url, string Reason, string Hint); + +public sealed record UrlCandidateDto +{ + public required string Id { get; init; } + public required string DisplayName { get; init; } + public required string Url { get; init; } +} + public sealed record WsbResponse { public IReadOnlyList? SiteIds { get; init; } public IReadOnlyList? UnknownIdsIgnored { get; init; } + public string? TargetUrl { get; init; } + public ResolvedFromUrlDto? ResolvedFromUrl { get; init; } + public TargetUrlIgnoredDto? TargetUrlIgnored { get; init; } public string? Wsb { get; init; } public string? Usage { get; init; } + public string? TargetUrlNote { get; init; } // 생성된 .wsb 명령이 악성 다운로더와 형태가 비슷해 오탐되는 것을 줄이려는 의도 설명(동작 투명성). public string? SecurityNote { get; init; } public string? Error { get; init; } public IReadOnlyList? UnknownIds { get; init; } + public IReadOnlyList? Candidates { get; init; } public string? Hint { get; init; } } @@ -85,11 +103,33 @@ public sealed record LaunchResponse public string? Runner { get; init; } public IReadOnlyList? SiteIds { get; init; } public IReadOnlyList? UnknownIdsIgnored { get; init; } + public string? TargetUrl { get; init; } + public ResolvedFromUrlDto? ResolvedFromUrl { get; init; } + public TargetUrlIgnoredDto? TargetUrlIgnored { get; init; } public string? WsbPath { get; init; } public string? Note { get; init; } + public string? TargetUrlNote { get; init; } // 실행되는 .wsb 가 격리된 일회용 환경 전용이며 호스트에 영향이 없음을 명시(동작 투명성). public string? SecurityNote { get; init; } public string? Error { get; init; } public IReadOnlyList? UnknownIds { get; init; } + public IReadOnlyList? Candidates { get; init; } + public string? Hint { get; init; } +} + +public sealed record CheckUrlResponse +{ + public required string Url { get; init; } + public string? Host { get; init; } + public required bool SandboxSupported { get; init; } + public string? Reason { get; init; } + public string? ServiceId { get; init; } + public string? DisplayName { get; init; } + public string? Category { get; init; } + public string? ServiceUrl { get; init; } + public IReadOnlyList? RequiredPackages { get; init; } + public IReadOnlyList? Candidates { get; init; } + public string? Note { get; init; } + public string? Error { get; init; } public string? Hint { get; init; } } diff --git a/node/src/index.ts b/node/src/index.ts index 7325fc4..3f4b38c 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -4,7 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { strings } from "./generated.js"; -import { searchServices, getService, listCategories, listCompanions } from "./tools/catalog-tools.js"; +import { searchServices, getService, listCategories, listCompanions, checkUrl } from "./tools/catalog-tools.js"; import { generateWsb, launchSandbox } from "./tools/sandbox-tools.js"; const T = strings.tools; @@ -63,15 +63,29 @@ server.registerTool( async ({ query }) => text(await listCompanions(query)), ); +server.registerTool( + "check_url", + { + title: T.check_url.title, + description: T.check_url.description, + inputSchema: { url: z.string().describe(T.check_url.params.url) }, + annotations: { title: T.check_url.title, ...RO }, + }, + async ({ url }) => text(await checkUrl(url)), +); + server.registerTool( "generate_wsb", { title: T.generate_wsb.title, description: T.generate_wsb.description, - inputSchema: { serviceIds: z.array(z.string()).describe(T.generate_wsb.params.serviceIds) }, + inputSchema: { + serviceIds: z.array(z.string()).default([]).describe(T.generate_wsb.params.serviceIds), + targetUrl: z.string().nullable().default(null).describe(T.generate_wsb.params.targetUrl), + }, annotations: { title: T.generate_wsb.title, ...RO }, }, - async ({ serviceIds }) => text(await generateWsb(serviceIds)), + async ({ serviceIds, targetUrl }) => text(await generateWsb(serviceIds, targetUrl)), ); server.registerTool( @@ -79,10 +93,13 @@ server.registerTool( { title: T.launch_sandbox.title, description: T.launch_sandbox.description, - inputSchema: { serviceIds: z.array(z.string()).describe(T.launch_sandbox.params.serviceIds) }, + inputSchema: { + serviceIds: z.array(z.string()).default([]).describe(T.launch_sandbox.params.serviceIds), + targetUrl: z.string().nullable().default(null).describe(T.launch_sandbox.params.targetUrl), + }, annotations: { title: T.launch_sandbox.title, readOnlyHint: false, destructiveHint: false, openWorldHint: true }, }, - async ({ serviceIds }) => text(await launchSandbox(serviceIds)), + async ({ serviceIds, targetUrl }) => text(await launchSandbox(serviceIds, targetUrl)), ); const transport = new StdioServerTransport(); diff --git a/node/src/tools/catalog-tools.ts b/node/src/tools/catalog-tools.ts index 0e924e1..67d5217 100644 --- a/node/src/tools/catalog-tools.ts +++ b/node/src/tools/catalog-tools.ts @@ -2,6 +2,7 @@ // 출력의 선택 필드는 undefined 로 두어 JSON.stringify 가 생략하게 한다(=.NET 의 WhenWritingNull). import { getCatalog, iconUrlFor, type CatalogService } from "../catalog.js"; import { strings } from "../generated.js"; +import { resolveByUrl, validateTargetUrl } from "./target-url.js"; const SEP = /[ ,\t\n\r]+/; const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); @@ -67,6 +68,61 @@ export async function getService(id: string) { }; } +/** + * URL 하나가 샌드박스가 필요한 카탈로그 사이트인지 판정한다(실행 없음, SPEC.md §5). + * 모델이 "샌드박스로 열기 / 그냥 브라우저로 열기"를 고르는 데 쓰는 정보만 돌려준다. + */ +export async function checkUrl(url: string) { + const S = strings.sandbox; + const v = validateTargetUrl(url); + if (!v.ok) { + return { + url: (url ?? "").trim(), + sandboxSupported: false, + reason: "invalid", + error: S.targetUrlInvalidError.replace("{reason}", v.reason), + hint: S.targetUrlInvalidHint, + }; + } + + const doc = await getCatalog(); + const r = resolveByUrl(doc.services, v.host); + if (r.kind === "noMatch") { + return { + url: v.url, + host: v.host, + sandboxSupported: false, + reason: "noMatch", + note: strings.tools.check_url.noteUnsupported, + hint: S.targetUrlNoMatchHint, + }; + } + if (r.kind === "ambiguous") { + return { + url: v.url, + host: v.host, + sandboxSupported: false, + reason: "ambiguous", + candidates: r.candidates, + error: S.targetUrlAmbiguousError.replace("{host}", v.host), + hint: S.targetUrlAmbiguousHint.replace("{candidates}", r.candidates.map((c) => c.id).join(", ")), + }; + } + + const svc = doc.services.find((s) => s.id === r.id)!; + return { + url: v.url, + host: v.host, + sandboxSupported: true, + serviceId: svc.id, + displayName: svc.displayName, + category: svc.category, + serviceUrl: svc.url, + requiredPackages: svc.packages.map((p) => p.name), + note: strings.tools.check_url.noteSupported, + }; +} + export async function listCategories() { const doc = await getCatalog(); const map = new Map(); diff --git a/node/src/tools/sandbox-tools.ts b/node/src/tools/sandbox-tools.ts index 198ac87..3b2361f 100644 --- a/node/src/tools/sandbox-tools.ts +++ b/node/src/tools/sandbox-tools.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { getCatalog } from "../catalog.js"; import { strings, wsbTemplate } from "../generated.js"; +import { escapeForWsb, hostOf, registeredDomain, resolveByUrl, validateTargetUrl } from "./target-url.js"; const SAFE_ID = /^[A-Za-z0-9._-]+$/; @@ -26,12 +27,91 @@ async function resolveIds(serviceIds: string[]): Promise<{ valid: string[]; unkn return { valid, unknown }; } -function buildWsb(validIds: string[]): string { - // 템플릿(정본)은 shared/wsb-template.xml. 사이트 사전선택만 __SITEIDS__ 로 주입. - const idsStmt = validIds.length === 0 - ? "" - : ` $env:TABLECLOTH_SITE_IDS = ''${validIds.join(" ")}'';`; - return wsbTemplate.split("__SITEIDS__").join(idsStmt); +function buildWsb(validIds: string[], targetUrl: string): string { + // 템플릿(정본)은 shared/wsb-template.xml — 상류 no-install-spork-deeplink.wsb 의 LogonCommand 와 바이트 일치. + // 채널 2개는 값 자리에만 들어간다. 미지정이면 빈 문자열(게스트가 "미지정"으로 취급). + // id 는 SAFE_ID 를 통과했으므로 이스케이프가 필요 없고, URL 만 두 층 이스케이프를 거친다. + return wsbTemplate + .split("__SPORK_SITE_IDS__").join(validIds.join(" ")) + .split("__SPORK_TARGET_URL__").join(targetUrl.length === 0 ? "" : escapeForWsb(targetUrl)); +} + +const S = strings.sandbox; + +interface TargetPlan { + ids: string[]; + url: string; + resolvedFromUrl?: { url: string; serviceId: string }; + targetUrlIgnored?: { url: string; reason: string; hint: string }; +} + +/** + * serviceIds 와 targetUrl 을 실제로 .wsb 에 실을 형태로 정리한다(양 레인 동일 규칙). + * 실패면 도구가 그대로 반환할 오류 객체를, 성공이면 계획을 돌려준다. + */ +async function planTarget(serviceIds: string[], targetUrl: string | null | undefined) { + const { valid, unknown } = await resolveIds(serviceIds ?? []); + const raw = (targetUrl ?? "").trim(); + // 오류 응답의 unknownIds 는 "비어 있으면 생략"이 규칙이다(.NET 의 WhenWritingNull 과 동일하게 보이도록). + const unknownIds = unknown.length > 0 ? unknown : undefined; + + if (raw.length === 0) { + if (valid.length === 0) return { error: { error: S.errorNoTarget, hint: S.hintNoTarget, unknownIds } }; + return { plan: { ids: valid, url: "" } as TargetPlan, unknown }; + } + + const v = validateTargetUrl(raw); + if (!v.ok) { + return { error: { error: S.targetUrlInvalidError.replace("{reason}", v.reason), hint: S.targetUrlInvalidHint, unknownIds } }; + } + + // id 가 함께 왔으면 게스트 규칙(§6.5-3)과 같게 판단한다: 모든 id 가 URL 과 같은 등록 도메인이어야 + // URL 이 살아남는다. 어긋나면 게스트도 URL 만 버리므로, 여기서 미리 버리고 사실대로 알린다. + if (valid.length > 0) { + const doc = await getCatalog(); + const rd = registeredDomain(v.host); + const coherent = valid.every((id) => { + const svc = doc.services.find((s) => s.id === id); + const h = svc ? hostOf(svc.url) : ""; + return h.length > 0 && registeredDomain(h) === rd; + }); + if (!coherent) { + return { + plan: { + ids: valid, + url: "", + targetUrlIgnored: { + url: v.url, + reason: S.targetUrlIdMismatchError.replace("{host}", v.host), + hint: S.targetUrlIdMismatchHint, + }, + } as TargetPlan, + unknown, + }; + } + return { plan: { ids: valid, url: v.url } as TargetPlan, unknown }; + } + + // URL 만 온 경우 — 카탈로그로 서비스를 특정한다. + const doc = await getCatalog(); + const r = resolveByUrl(doc.services, v.host); + if (r.kind === "noMatch") { + return { error: { error: S.targetUrlNoMatchError.replace("{host}", v.host), hint: S.targetUrlNoMatchHint, unknownIds } }; + } + if (r.kind === "ambiguous") { + return { + error: { + error: S.targetUrlAmbiguousError.replace("{host}", v.host), + hint: S.targetUrlAmbiguousHint.replace("{candidates}", r.candidates.map((c) => c.id).join(", ")), + candidates: r.candidates, + unknownIds, + }, + }; + } + return { + plan: { ids: [r.id], url: v.url, resolvedFromUrl: { url: v.url, serviceId: r.id } } as TargetPlan, + unknown, + }; } // macSandbox 앱(`macSandbox for Windows.app`, 번들 ID com.rkttu.macsandbox)의 위치를 찾는다. @@ -68,29 +148,29 @@ function resolveMacSandbox(): string | undefined { return undefined; } -export async function generateWsb(serviceIds: string[]) { - const { valid, unknown } = await resolveIds(serviceIds); - if (valid.length === 0) { - return { - error: strings.tools.generate_wsb.errorNoValidIds, - unknownIds: unknown, - hint: strings.tools.generate_wsb.hintNoValidIds, - }; - } +export async function generateWsb(serviceIds: string[], targetUrl?: string | null) { + const r = await planTarget(serviceIds, targetUrl); + if (r.error) return r.error; + const { ids, url, resolvedFromUrl, targetUrlIgnored } = r.plan!; + const unknown = r.unknown ?? []; return { - siteIds: valid, + siteIds: ids, unknownIdsIgnored: unknown.length > 0 ? unknown : undefined, - wsb: buildWsb(valid), + targetUrl: url.length > 0 ? url : undefined, + resolvedFromUrl, + targetUrlIgnored, + wsb: buildWsb(ids, url), usage: strings.tools.generate_wsb.usage, + targetUrlNote: url.length > 0 ? S.targetUrlNote : undefined, securityNote: strings.sandbox.securityNote, }; } -export async function launchSandbox(serviceIds: string[]) { - const { valid, unknown } = await resolveIds(serviceIds); - if (valid.length === 0) { - return { launched: false, error: strings.tools.launch_sandbox.errorNoValidIds, unknownIds: unknown, hint: strings.tools.launch_sandbox.hintNoValidIds }; - } +export async function launchSandbox(serviceIds: string[], targetUrl?: string | null) { + const r = await planTarget(serviceIds, targetUrl); + if (r.error) return { launched: false, ...r.error }; + const { ids: valid, url, resolvedFromUrl, targetUrlIgnored } = r.plan!; + const unknown = r.unknown ?? []; let runner: string; let command: string; @@ -115,7 +195,7 @@ export async function launchSandbox(serviceIds: string[]) { return { launched: false, error: strings.tools.launch_sandbox.runnerUnsupportedError, hint: strings.tools.launch_sandbox.runnerUnsupportedHint }; } - const wsb = buildWsb(valid); + const wsb = buildWsb(valid, url); const path = join(tmpdir(), `tablecloth-${randomUUID().replaceAll("-", "")}.wsb`); await writeFile(path, wsb, "utf8"); @@ -137,8 +217,12 @@ export async function launchSandbox(serviceIds: string[]) { runner, siteIds: valid, unknownIdsIgnored: unknown.length > 0 ? unknown : undefined, + targetUrl: url.length > 0 ? url : undefined, + resolvedFromUrl, + targetUrlIgnored, wsbPath: path, note: strings.tools.launch_sandbox.noteTemplate.replace("{runner}", runner), + targetUrlNote: url.length > 0 ? S.targetUrlNote : undefined, securityNote: strings.sandbox.securityNote, }; } catch (e) { diff --git a/node/src/tools/target-url.ts b/node/src/tools/target-url.ts new file mode 100644 index 0000000..577a7ea --- /dev/null +++ b/node/src/tools/target-url.ts @@ -0,0 +1,131 @@ +// targetUrl 채널(PARAMETERIZED_WSB_SPEC.md §3.3/§6.5) 구현. .NET TargetUrl.cs 와 규칙이 동일해야 한다 +// — 같은 입력에 같은 바이트를 내야 conformance 가 통과한다(SPEC.md §7.1). +// +// 설계 메모: URL 을 URL 클래스로 파싱해 재조립하지 않는다. Node 의 `new URL()` 과 .NET 의 `Uri` 는 +// 정규화 규칙(트레일링 슬래시, 퍼센트 인코딩 대소문자, IDN)이 서로 달라서 두 구현이 갈라진다. +// 검증은 문자열 수준에서 직접 하고, .wsb 에는 원문을 그대로(이스케이프만 해서) 싣는다. + +export const MAX_TARGET_URL_LENGTH = 2048; + +// 한국 2단계 퍼블릭 서픽스. 이 처리가 없으면 "마지막 두 라벨"이 등록 도메인이 되어 +// co.kr 전체(카탈로그 기준 95개)가 한 덩어리로 묶인다 — 선택이 아니라 필수다. +const KR_SECOND_LEVEL = new Set([ + "co.kr", "or.kr", "go.kr", "ne.kr", "re.kr", "pe.kr", + "ac.kr", "ms.kr", "hs.kr", "es.kr", "sc.kr", "kg.kr", +]); + +export type UrlRejectReason = "empty" | "tooLong" | "badChars" | "notHttp" | "credentials" | "noHost"; + +export type UrlValidation = + | { ok: true; url: string; host: string } + | { ok: false; reason: UrlRejectReason }; + +/** 생산자 측 검증(§3.3): http/https 절대 URL, 자격증명 없음, 2048자 이하, 공백/제어문자 없음. */ +export function validateTargetUrl(raw: string | null | undefined): UrlValidation { + const url = (raw ?? "").trim(); + if (url.length === 0) return { ok: false, reason: "empty" }; + if (url.length > MAX_TARGET_URL_LENGTH) return { ok: false, reason: "tooLong" }; + for (const ch of url) { + const c = ch.codePointAt(0)!; + if (c <= 0x20 || c === 0x7f) return { ok: false, reason: "badChars" }; + } + const m = /^https?:\/\/([^/?#]*)/i.exec(url); + if (!m) return { ok: false, reason: "notHttp" }; + const authority = m[1]; + if (authority.includes("@")) return { ok: false, reason: "credentials" }; + const host = stripPort(authority).toLowerCase(); + if (host.length === 0) return { ok: false, reason: "noHost" }; + return { ok: true, url, host }; +} + +function stripPort(authority: string): string { + if (authority.startsWith("[")) { + const end = authority.indexOf("]"); // IPv6 리터럴 + return end < 0 ? authority : authority.slice(0, end + 1); + } + const colon = authority.indexOf(":"); + return colon < 0 ? authority : authority.slice(0, colon); +} + +/** 카탈로그 서비스 URL 에서 호스트만 뽑는다(검증 실패 시 빈 문자열). */ +export function hostOf(url: string): string { + const v = validateTargetUrl(url); + return v.ok ? v.host : ""; +} + +/** 퍼블릭 서픽스를 인식한 등록 도메인(§6.5-2). */ +export function registeredDomain(host: string): string { + const labels = host.split(".").filter((l) => l.length > 0); + if (labels.length <= 2) return labels.join("."); + const last2 = labels.slice(-2).join("."); + return KR_SECOND_LEVEL.has(last2) ? labels.slice(-3).join(".") : last2; +} + +/** + * .wsb 주입용 이스케이프. 두 층을 순서대로 거친다(§3.3). + * 1) PowerShell/argv 층: 중첩 따옴표를 깨는 ' 와 " 를 퍼센트 인코딩. + * 덤으로 비ASCII 도 퍼센트 인코딩한다 — 정본 .wsb 가 "ASCII only"를 요구하고(게스트 코드페이지가 + * 호스트 언어팩마다 달라 한글이 깨진다), macSandbox 는 명령을 .cmd 파일로 한 번 더 경유시킨다. + * 2) XML 층: & < > 를 엔티티로. 실제 은행 URL 의 쿼리스트링에 & 가 흔해 필수다. + * 두 층이 건드리는 문자 집합은 서로 겹치지 않아 순서 자체는 결과에 영향이 없지만, 구현 간 + * 동일성을 위해 순서를 고정한다. + */ +export function escapeForWsb(value: string): string { + let out = ""; + for (const ch of value) { + const cp = ch.codePointAt(0)!; + if (ch === "'" || ch === '"' || cp > 0x7e) out += percentEncode(ch); + else out += ch; + } + return out.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +function percentEncode(ch: string): string { + let out = ""; + for (const b of new TextEncoder().encode(ch)) out += "%" + b.toString(16).toUpperCase().padStart(2, "0"); + return out; +} + +export interface UrlCandidate { id: string; displayName: string; url: string; } + +export type UrlResolution = + | { kind: "match"; id: string } + | { kind: "ambiguous"; candidates: UrlCandidate[] } + | { kind: "noMatch" }; + +/** + * URL 하나를 카탈로그 서비스로 해석한다(§6.5-4 의 호스트측 대응). + * 등록 도메인이 같은 후보 중 호스트 라벨이 가장 많이 일치하는 하나를 고른다. + * 게스트는 동점일 때 조용히 카탈로그 선순위를 택하지만, 여기서는 추측하지 않고 후보를 돌려준다 + * — 호스트에는 되물을 수 있는 모델이 있고, 잘못 고르면 엉뚱한 보안프로그램이 설치되기 때문. + */ +export function resolveByUrl( + services: readonly { id: string; displayName: string; url: string }[], + host: string, +): UrlResolution { + const rd = registeredDomain(host); + if (rd.length === 0) return { kind: "noMatch" }; + + const pool = services.filter((s) => { + const h = hostOf(s.url); + return h.length > 0 && registeredDomain(h) === rd; + }); + if (pool.length === 0) return { kind: "noMatch" }; + + const target = host.split(".").filter((l) => l.length > 0); + const score = (svcHost: string) => { + const labels = svcHost.split(".").filter((l) => l.length > 0); + let n = 0; + while (n < labels.length && n < target.length && labels[labels.length - 1 - n] === target[target.length - 1 - n]) n++; + return n; + }; + + const scored = pool.map((s) => ({ s, score: score(hostOf(s.url)) })); + const best = Math.max(...scored.map((x) => x.score)); + const top = scored.filter((x) => x.score === best).map((x) => x.s); + if (top.length === 1) return { kind: "match", id: top[0].id }; + return { + kind: "ambiguous", + candidates: top.map((s) => ({ id: s.id, displayName: s.displayName, url: s.url })), + }; +} diff --git a/node/test/conformance.mjs b/node/test/conformance.mjs index 87155b0..ed8596a 100644 --- a/node/test/conformance.mjs +++ b/node/test/conformance.mjs @@ -42,6 +42,9 @@ async function collect(cmd, args) { c.send({ jsonrpc: "2.0", id: 0, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "conf", version: "0" } } }); const init = await c.wait(0); c.send({ jsonrpc: "2.0", method: "notifications/initialized" }); + // targetUrl 채널 케이스: 딥 URL(쿼리스트링의 & → XML 엔티티), URL-only 자동 판별, + // 동점(www.kebhana.com), 카탈로그 밖 도메인, id/URL 도메인 불일치, 형식 위반, 비ASCII 경로. + const DEEP_URL = "https://www.wooribank.com/pot/Dream?withyou=CTCER0149&fromSite=pib"; const reqs = [ [1, "tools/list", undefined], [2, "tools/call", { name: "generate_wsb", arguments: { serviceIds: ["Hometax"] } }], @@ -49,6 +52,15 @@ async function collect(cmd, args) { [4, "tools/call", { name: "list_categories", arguments: {} }], [5, "tools/call", { name: "get_service", arguments: { id: "Hometax" } }], [6, "tools/call", { name: "list_companions", arguments: {} }], + [7, "tools/call", { name: "generate_wsb", arguments: { serviceIds: ["WooriBank"], targetUrl: DEEP_URL } }], + [8, "tools/call", { name: "generate_wsb", arguments: { serviceIds: [], targetUrl: "https://obiz.kbstar.com/quics?page=obiz" } }], + [9, "tools/call", { name: "generate_wsb", arguments: { serviceIds: [], targetUrl: "https://www.kebhana.com/efamily/h/hanasavingsbank/main.jsp" } }], + [10, "tools/call", { name: "generate_wsb", arguments: { serviceIds: [], targetUrl: "https://evilwooribank.com/phish" } }], + [11, "tools/call", { name: "generate_wsb", arguments: { serviceIds: ["Hometax"], targetUrl: DEEP_URL } }], + [12, "tools/call", { name: "generate_wsb", arguments: { serviceIds: [], targetUrl: "ftp://www.wooribank.com/x" } }], + [13, "tools/call", { name: "generate_wsb", arguments: { serviceIds: [], targetUrl: "https://www.hometax.go.kr/한글/path?q=값&r='x'" } }], + [14, "tools/call", { name: "check_url", arguments: { url: DEEP_URL } }], + [15, "tools/call", { name: "check_url", arguments: { url: "https://example.com/nothing" } }], ]; for (const [id, method, params] of reqs) c.send({ jsonrpc: "2.0", id, method, params }); const body = async (id) => JSON.parse((await c.wait(id)).result.content[0].text); @@ -60,6 +72,15 @@ async function collect(cmd, args) { cats: await body(4), getSvc: await body(5), companions: await body(6), + urlDeep: await body(7), + urlOnly: await body(8), + urlAmbiguous: await body(9), + urlNoMatch: await body(10), + urlMismatch: await body(11), + urlInvalid: await body(12), + urlNonAscii: await body(13), + checkOk: await body(14), + checkNo: await body(15), }; c.close(); return out; @@ -135,5 +156,30 @@ console.log("\n[8] list_companions parity"); check("matched equal", net.companions.matched === node.companions.matched, `net=${net.companions.matched} node=${node.companions.matched}`); check("companion id set equal", canon(ids(net.companions.companions)) === canon(ids(node.companions.companions))); +console.log("\n[9] targetUrl 채널 parity (.NET vs Node)"); +// 두 구현이 같은 URL 을 같은 바이트로 이스케이프하고 같은 서비스로 해석해야 한다. +for (const k of ["urlDeep", "urlOnly", "urlAmbiguous", "urlNoMatch", "urlMismatch", "urlInvalid", "urlNonAscii", "checkOk", "checkNo"]) { + check(`'${k}' 응답 동일`, canon(net[k]) === canon(node[k]), + `net=${canon(net[k])}\n node=${canon(node[k])}`); +} + +console.log("\n[10] targetUrl 주입/이스케이프 규칙"); +// 딥 URL 은 .wsb 에 실려야 하고, 쿼리스트링의 & 는 XML 엔티티가 되어야 한다. +check("딥 URL 이 .wsb 에 주입됨", net.urlDeep.wsb?.includes("withyou=CTCER0149&fromSite=pib") === true); +check("원문 & 가 .wsb 에 날것으로 남지 않음", net.urlDeep.wsb?.includes("withyou=CTCER0149&f") === false); +check("URL-only 는 카탈로그로 서비스를 판별", net.urlOnly.resolvedFromUrl?.serviceId === "KookminBankBiz", + `resolvedFromUrl=${JSON.stringify(net.urlOnly.resolvedFromUrl)}`); +check("동점이면 추측하지 않고 후보 반환", Array.isArray(net.urlAmbiguous.candidates) && net.urlAmbiguous.candidates.length >= 2 && !net.urlAmbiguous.wsb); +check("카탈로그 밖 도메인은 거부", !net.urlNoMatch.wsb && typeof net.urlNoMatch.error === "string"); +check("id/URL 도메인 불일치는 URL 만 버리고 id 는 유지", + !!net.urlMismatch.wsb && !net.urlMismatch.targetUrl && !!net.urlMismatch.targetUrlIgnored && canon(net.urlMismatch.siteIds) === canon(["Hometax"])); +check("http/https 아니면 거부", !net.urlInvalid.wsb && typeof net.urlInvalid.error === "string"); +// 정본 .wsb 는 ASCII only 를 요구한다(게스트 코드페이지가 호스트 언어팩마다 달라 한글이 깨진다). +check("비ASCII/따옴표는 퍼센트 인코딩되어 .wsb 가 ASCII 유지", + typeof net.urlNonAscii.wsb === "string" && /^[\x00-\x7F]*$/.test(net.urlNonAscii.wsb), + `wsb 에 비ASCII 잔존`); +check("check_url 이 서비스와 필요 패키지를 판정", net.checkOk.sandboxSupported === true && net.checkOk.serviceId === "WooriBank" && Array.isArray(net.checkOk.requiredPackages)); +check("카탈로그 밖 URL 은 sandboxSupported=false", net.checkNo.sandboxSupported === false); + console.log(`\n${fails === 0 ? "ALL CONFORMANCE CHECKS PASSED" : `${fails} CHECK(S) FAILED`}`); process.exit(fails === 0 ? 0 : 1); diff --git a/shared/strings.json b/shared/strings.json index ef58537..2d5671d 100644 --- a/shared/strings.json +++ b/shared/strings.json @@ -34,11 +34,21 @@ "query": "이름/URL 부분 일치 필터(선택)." } }, + "check_url": { + "title": "URL 샌드박스 필요 여부 판정", + "description": "웹 검색 등으로 얻은 페이지 URL 이 보안프로그램(공동인증서, 키보드보안 등)이 필요한 한국 공공/금융 사이트인지 판정한다. 아무것도 실행하지 않고 판정 정보만 돌려준다.\n\n이럴 때 사용하라: 대화 중 특정 은행/공공기관의 상품·안내 페이지 URL 을 확보했고, 사용자가 그 페이지로 접속하려 할 때. 이 도구로 먼저 판정한 뒤, 샌드박스가 필요하면 launch_sandbox 에 그 URL 을 targetUrl 로 넘겨 열고, 필요 없으면 사용자가 평소 브라우저로 열도록 안내한다.\n\n이 서버는 호스트(사용자 PC)의 브라우저를 직접 열지 않는다. 판정과 샌드박스 실행만 담당한다.", + "params": { + "url": "판정할 페이지의 전체 URL(http/https 절대 URL)." + }, + "noteSupported": "이 사이트는 보안프로그램이 필요합니다. launch_sandbox 에 이 URL 을 targetUrl 로 넘기면 보안프로그램 설치 후 이 페이지가 그대로 열립니다.", + "noteUnsupported": "이 URL 은 TableCloth 카탈로그 대상이 아니라 샌드박스로 열 수 없습니다. 보안프로그램이 필요 없는 페이지라면 사용자가 평소 쓰는 브라우저로 열면 됩니다." + }, "generate_wsb": { "title": "샌드박스 설정(.wsb) 생성", - "description": "선택한 service id 들로 실행할 Windows Sandbox 설정(.wsb) XML 텍스트를 생성해 반환한다(파일 실행은 안 함). 모든 OS 에서 호출 가능 — 사용자에게 .wsb 를 건네 더블클릭하게 할 때 쓴다. 생성된 .wsb 는 GitHub 릴리스의 공개 자산만 받아 동작하며, 지정한 사이트들의 보안프로그램을 샌드박스 안에서 자동 설치한 뒤 사이트를 연다. 로그인/인증/업무는 사용자 몫.", + "description": "선택한 service id 들(또는 페이지 URL)로 실행할 Windows Sandbox 설정(.wsb) XML 텍스트를 생성해 반환한다(파일 실행은 안 함). 모든 OS 에서 호출 가능 — 사용자에게 .wsb 를 건네 더블클릭하게 할 때 쓴다. 생성된 .wsb 는 GitHub 릴리스의 공개 자산만 받아 동작하며, 지정한 사이트들의 보안프로그램을 샌드박스 안에서 자동 설치한 뒤 사이트를 연다. 로그인/인증/업무는 사용자 몫.", "params": { - "serviceIds": "샌드박스에서 열 카탈로그 service id 목록(1개 이상). search_services 로 확인." + "serviceIds": "샌드박스에서 열 카탈로그 service id 목록. search_services 로 확인. targetUrl 을 주면 생략할 수 있다.", + "targetUrl": "샌드박스 안에서 열 정확한 페이지 URL(선택). 검색으로 찾은 딥 URL 을 그대로 주면 보안프로그램 설치 후 그 페이지가 열린다. 생략하면 서비스의 대표 URL 이 열린다. serviceIds 없이 이것만 줘도 카탈로그에서 해당 서비스를 자동 판별한다." }, "usage": "이 XML 을 .wsb 파일로 저장해 실행하면 지정한 사이트들이 보안프로그램과 함께 준비됩니다. 러너: Windows → Windows Sandbox(Windows 11 기능, 더블클릭), macOS(Apple Silicon) → macSandbox(.wsb 더블클릭).", "errorNoValidIds": "유효한 service id 가 없습니다.", @@ -48,7 +58,8 @@ "title": "샌드박스로 사이트 열기", "description": "search_services 로 얻은 service id 들로, 그 한국 은행/공공 사이트를 보안프로그램이 갖춰진 일회용 샌드박스에서 즉시 연다. 사용자가 은행 계좌 개설/가입/로그인, 인터넷뱅킹, 홈택스, 정부24 등 보안프로그램이 필요한 사이트를 실제로 이용하거나 신청하려 할 때 사용한다.\n\n핵심 동작: 이용 절차나 로그인 방법, '보안 프로그램 설치가 필요하다'는 안내를 텍스트로 길게 설명하는 대신, 이 도구로 그 공식 사이트를 바로 열어 사용자가 보안프로그램 설치 번거로움 없이 진행하게 한다. 사용자가 'PC로 만들래', '신청할래', '가입할래'처럼 말하거나, 네가 '보안 프로그램 설치가 필요하다'고 안내하려는 순간이 바로 이 도구를 쓸 때다('샌드박스'라는 말이 없어도).\n\n러너: Windows 는 Windows Sandbox, macOS(Apple Silicon)는 macSandbox, 그 외(Linux 등)는 환경변수 TABLECLOTH_WSB_RUNNER 에 지정한 러너(모든 OS 에서 기본값보다 우선, .wsb 경로를 첫 인자로 받음). 샌드박스가 뜨면 보안프로그램이 자동 설치되고 사이트가 열린다. 러너가 없으면 generate_wsb 로 .wsb 를 받아 실행한다. 로그인, 인증(공동/금융/간편인증), 실제 신청은 사용자가 직접 한다(RPA 아님).", "params": { - "serviceIds": "열 카탈로그 service id 목록(1개 이상). 여러 개면 한 샌드박스에 병합 설치된다." + "serviceIds": "열 카탈로그 service id 목록. 여러 개면 한 샌드박스에 병합 설치된다. targetUrl 을 주면 생략할 수 있다.", + "targetUrl": "샌드박스 안에서 열 정확한 페이지 URL(선택). 검색으로 찾은 딥 URL 을 그대로 주면 보안프로그램 설치 후 그 페이지가 열린다. 생략하면 서비스의 대표 URL 이 열린다. serviceIds 없이 이것만 줘도 카탈로그에서 해당 서비스를 자동 판별한다." }, "noteTemplate": "{runner} 준비(보안프로그램 설치)에 수 분 걸릴 수 있습니다. 인증/로그인은 사용자가 직접 진행하세요.", "errorNoValidIds": "유효한 service id 가 없습니다.", @@ -62,6 +73,17 @@ } }, "sandbox": { - "securityNote": "이 .wsb 는 공식 TableCloth 준비 스크립트(GitHub 릴리스, HTTPS)를 일회용 샌드박스 안에서만 실행합니다. 호스트 시스템에는 접근하거나 영향을 주지 않고, 샌드박스를 닫으면 모두 사라집니다. ExecutionPolicy Bypass 와 원격 스크립트 실행은 이 격리된 일회용 환경 내부에서만 일어납니다. 자격증명/로그인/이체는 수행하지 않으며 사용자가 직접 진행합니다." + "securityNote": "이 .wsb 는 공식 TableCloth 준비 스크립트(GitHub 릴리스, HTTPS)를 일회용 샌드박스 안에서만 실행합니다. 호스트 시스템에는 접근하거나 영향을 주지 않고, 샌드박스를 닫으면 모두 사라집니다. ExecutionPolicy Bypass 와 원격 스크립트 실행은 이 격리된 일회용 환경 내부에서만 일어납니다. 자격증명/로그인/이체는 수행하지 않으며 사용자가 직접 진행합니다.", + "targetUrlNote": "지정한 페이지는 샌드박스 안에서 보안프로그램 설치가 끝난 뒤 열립니다. 호스트(사용자 PC)의 브라우저는 열지 않습니다. URL 은 .wsb 에 평문으로 남아 있어 실행 전에 어떤 주소가 열리는지 확인할 수 있습니다.", + "errorNoTarget": "열 대상이 없습니다. serviceIds 나 targetUrl 중 최소 하나가 필요합니다.", + "hintNoTarget": "search_services 로 service id 를 찾거나, 열고 싶은 페이지의 전체 URL 을 targetUrl 로 주세요.", + "targetUrlInvalidError": "targetUrl 이 허용되는 형식이 아닙니다({reason}).", + "targetUrlInvalidHint": "http:// 또는 https:// 로 시작하는 절대 URL 을, 자격증명(user@host)·공백·제어문자 없이 2048자 이하로 주세요.", + "targetUrlNoMatchError": "이 URL 의 도메인({host})은 TableCloth 카탈로그에 없습니다.", + "targetUrlNoMatchHint": "카탈로그에 등록된 사이트만 샌드박스로 열 수 있습니다. search_services 로 확인하세요. 보안프로그램이 필요 없는 페이지라면 사용자가 평소 브라우저로 열면 됩니다.", + "targetUrlAmbiguousError": "이 URL({host})만으로는 어떤 카탈로그 서비스인지 특정할 수 없습니다.", + "targetUrlAmbiguousHint": "serviceIds 로 서비스를 명시해 주세요. 후보: {candidates}", + "targetUrlIdMismatchError": "targetUrl 의 도메인({host})이 지정한 serviceIds 의 도메인과 달라 URL 을 무시했습니다.", + "targetUrlIdMismatchHint": "같은 사이트의 URL 을 주거나, targetUrl 없이 serviceIds 만 사용하세요. 샌드박스는 서비스의 대표 URL 을 엽니다." } } diff --git a/shared/wsb-template.xml b/shared/wsb-template.xml index 78a6e01..7f7479e 100644 --- a/shared/wsb-template.xml +++ b/shared/wsb-template.xml @@ -3,6 +3,6 @@ Enable Disable - powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-Process powershell.exe -WindowStyle Normal -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-Command','$Host.UI.RawUI.WindowTitle = ''TableCloth Setup''; Write-Host '' Getting TableCloth ready...'' -ForegroundColor Cyan; if (-not (Resolve-DnsName -Name github.com -QuickTimeout -ErrorAction SilentlyContinue)) { Get-NetAdapter | Where-Object Status -eq ''Up'' | Set-DnsClientServerAddress -ServerAddresses 8.8.8.8,1.1.1.1 }; [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072;__SITEIDS__ try { iex ((New-Object Net.WebClient).DownloadString(''https://github.com/yourtablecloth/TableCloth/releases/latest/download/tablecloth-prepare.ps1'')) } catch { Write-Host ('' Failed: '' + $_.Exception.Message) -ForegroundColor Red; $null = Read-Host '' Press Enter to close'' }'" + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "Start-Process powershell.exe -WindowStyle Normal -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-Command','$Host.UI.RawUI.WindowTitle = ''TableCloth Setup''; Write-Host '' Getting TableCloth ready...'' -ForegroundColor Cyan; if (-not (Resolve-DnsName -Name github.com -QuickTimeout -ErrorAction SilentlyContinue)) { Get-NetAdapter | Where-Object Status -eq ''Up'' | Set-DnsClientServerAddress -ServerAddresses 8.8.8.8,1.1.1.1 }; [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072; $env:TABLECLOTH_SITE_IDS = ''__SPORK_SITE_IDS__''; $env:TABLECLOTH_TARGET_URL = ''__SPORK_TARGET_URL__''; try { iex ((New-Object Net.WebClient).DownloadString(''https://github.com/yourtablecloth/TableCloth/releases/latest/download/tablecloth-prepare.ps1'')) } catch { Write-Host ('' Failed: '' + $_.Exception.Message) -ForegroundColor Red; $null = Read-Host '' Press Enter to close'' }'"