From 4661a00f42f3708975e79fab1282eb48086c281f Mon Sep 17 00:00:00 2001 From: "Jung Hyun, Nam" Date: Sun, 25 Oct 2026 21:29:42 +0900 Subject: [PATCH 1/4] =?UTF-8?q?AppContainer=20=EA=B8=B0=EB=B0=98=20Python?= =?UTF-8?q?=20=EB=B0=8F=20Node=20=EC=8A=A4=ED=82=AC=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=20PoC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 1 + docs/SKILL_MARKETPLACE.ko.md | 2 + docs/SKILL_MARKETPLACE.md | 2 + external/TableClothCatalog | 2 +- .../SkillMarketplaceTests.cs | 17 +- tools/appcontainer-skill-poc/.gitattributes | 2 + .../Broker/EvaluationManifest.cs | 250 +++++ tools/appcontainer-skill-poc/Broker/README.md | 69 ++ .../Broker/SelfChecks.cs | 287 ++++++ .../Broker/SkillExecutionRegistry.cs | 157 +++ .../Broker/SkillMcpBroker.cs | 330 +++++++ tools/appcontainer-skill-poc/CodexProbe.cs | 428 +++++++++ tools/appcontainer-skill-poc/Evaluation.cs | 294 ++++++ .../appcontainer-skill-poc/Native/AclScope.cs | 395 ++++++++ .../Native/AppContainerProcessRunner.cs | 400 ++++++++ .../Native/OwnedPaths.cs | 83 ++ .../Native/SandboxRunContracts.cs | 23 + tools/appcontainer-skill-poc/Native/Win32.cs | 248 +++++ tools/appcontainer-skill-poc/Poc.csproj | 10 + tools/appcontainer-skill-poc/Preparation.cs | 137 +++ .../Probes/isolation.cjs | 34 + .../Probes/isolation.py | 37 + tools/appcontainer-skill-poc/Probes/memory.py | 8 + .../Probes/node-json.cjs | 9 + tools/appcontainer-skill-poc/Probes/output.py | 4 + .../appcontainer-skill-poc/Probes/unicode.py | 5 + tools/appcontainer-skill-poc/Probes/wait.py | 2 + tools/appcontainer-skill-poc/Program.cs | 27 + tools/appcontainer-skill-poc/README.ko.md | 71 ++ tools/appcontainer-skill-poc/README.md | 71 ++ tools/appcontainer-skill-poc/RESULTS.ko.md | 58 ++ tools/appcontainer-skill-poc/RESULTS.md | 58 ++ .../evaluation-summary.json | 309 ++++++ .../skills/ORIGINS.json | 253 +++++ .../skills/README.ko.md | 37 + tools/appcontainer-skill-poc/skills/README.md | 47 + .../skills/bok-ecos-stats/LICENSE | 21 + .../skills/bok-ecos-stats/SKILL.md | 41 + .../skills/bok-ecos-stats/instruction.md | 95 ++ .../skills/bok-ecos-stats/scripts/bok_ecos.py | 302 ++++++ .../skills/bok-ecos-stats/skill.json | 9 + .../bok-ecos-stats/tests/test_bok_ecos.py | 258 +++++ .../skills/kosis-stats/LICENSE | 21 + .../skills/kosis-stats/SKILL.md | 41 + .../skills/kosis-stats/instruction.md | 240 +++++ .../references/kosis-openapi-guide.md | 175 ++++ .../kosis-stats/scripts/run_kosis_stats.py | 896 ++++++++++++++++++ .../skills/kosis-stats/skill.json | 9 + .../skills/kosis-stats/tests/__init__.py | 0 .../tests/fixtures/data_response.json | 28 + .../tests/fixtures/meta_response.json | 6 + .../tests/fixtures/search_response.json | 17 + .../kosis-stats/tests/test_run_kosis_stats.py | 576 +++++++++++ .../.github/workflows/test.yml | 27 + .../skills/tablecloth-calculators/.gitignore | 11 + .../tablecloth-calculators/CONTRIBUTING.ko.md | 41 + .../tablecloth-calculators/CONTRIBUTING.md | 41 + .../tablecloth-calculators/LICENSE-AGPL | 661 +++++++++++++ .../tablecloth-calculators/LICENSE-COMMERCIAL | 3 + .../tablecloth-calculators/README.ko.md | 52 + .../skills/tablecloth-calculators/README.md | 52 + .../skills/tablecloth-calculators/SKILL.md | 23 + .../docs/COPYRIGHT.ko.md | 29 + .../tablecloth-calculators/docs/COPYRIGHT.md | 29 + .../references/operations.md | 161 ++++ .../scripts/calculate.py | 474 +++++++++ .../tests/test_calculate.py | 284 ++++++ 67 files changed, 8786 insertions(+), 4 deletions(-) create mode 100644 tools/appcontainer-skill-poc/.gitattributes create mode 100644 tools/appcontainer-skill-poc/Broker/EvaluationManifest.cs create mode 100644 tools/appcontainer-skill-poc/Broker/README.md create mode 100644 tools/appcontainer-skill-poc/Broker/SelfChecks.cs create mode 100644 tools/appcontainer-skill-poc/Broker/SkillExecutionRegistry.cs create mode 100644 tools/appcontainer-skill-poc/Broker/SkillMcpBroker.cs create mode 100644 tools/appcontainer-skill-poc/CodexProbe.cs create mode 100644 tools/appcontainer-skill-poc/Evaluation.cs create mode 100644 tools/appcontainer-skill-poc/Native/AclScope.cs create mode 100644 tools/appcontainer-skill-poc/Native/AppContainerProcessRunner.cs create mode 100644 tools/appcontainer-skill-poc/Native/OwnedPaths.cs create mode 100644 tools/appcontainer-skill-poc/Native/SandboxRunContracts.cs create mode 100644 tools/appcontainer-skill-poc/Native/Win32.cs create mode 100644 tools/appcontainer-skill-poc/Poc.csproj create mode 100644 tools/appcontainer-skill-poc/Preparation.cs create mode 100644 tools/appcontainer-skill-poc/Probes/isolation.cjs create mode 100644 tools/appcontainer-skill-poc/Probes/isolation.py create mode 100644 tools/appcontainer-skill-poc/Probes/memory.py create mode 100644 tools/appcontainer-skill-poc/Probes/node-json.cjs create mode 100644 tools/appcontainer-skill-poc/Probes/output.py create mode 100644 tools/appcontainer-skill-poc/Probes/unicode.py create mode 100644 tools/appcontainer-skill-poc/Probes/wait.py create mode 100644 tools/appcontainer-skill-poc/Program.cs create mode 100644 tools/appcontainer-skill-poc/README.ko.md create mode 100644 tools/appcontainer-skill-poc/README.md create mode 100644 tools/appcontainer-skill-poc/RESULTS.ko.md create mode 100644 tools/appcontainer-skill-poc/RESULTS.md create mode 100644 tools/appcontainer-skill-poc/evaluation-summary.json create mode 100644 tools/appcontainer-skill-poc/skills/ORIGINS.json create mode 100644 tools/appcontainer-skill-poc/skills/README.ko.md create mode 100644 tools/appcontainer-skill-poc/skills/README.md create mode 100644 tools/appcontainer-skill-poc/skills/bok-ecos-stats/LICENSE create mode 100644 tools/appcontainer-skill-poc/skills/bok-ecos-stats/SKILL.md create mode 100644 tools/appcontainer-skill-poc/skills/bok-ecos-stats/instruction.md create mode 100644 tools/appcontainer-skill-poc/skills/bok-ecos-stats/scripts/bok_ecos.py create mode 100644 tools/appcontainer-skill-poc/skills/bok-ecos-stats/skill.json create mode 100644 tools/appcontainer-skill-poc/skills/bok-ecos-stats/tests/test_bok_ecos.py create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/LICENSE create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/SKILL.md create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/instruction.md create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/references/kosis-openapi-guide.md create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/scripts/run_kosis_stats.py create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/skill.json create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/tests/__init__.py create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/data_response.json create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/meta_response.json create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/search_response.json create mode 100644 tools/appcontainer-skill-poc/skills/kosis-stats/tests/test_run_kosis_stats.py create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/.github/workflows/test.yml create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/.gitignore create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/CONTRIBUTING.ko.md create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/CONTRIBUTING.md create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/LICENSE-AGPL create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/LICENSE-COMMERCIAL create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/README.ko.md create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/README.md create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/SKILL.md create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/docs/COPYRIGHT.ko.md create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/docs/COPYRIGHT.md create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/references/operations.md create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/scripts/calculate.py create mode 100644 tools/appcontainer-skill-poc/skills/tablecloth-calculators/tests/test_calculate.py diff --git a/README.md b/README.md index eb8736eb..8cb72bfa 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ WinGet 등록소 반영에는 GitHub Release 게시 이후 시간이 더 걸릴 - [Avalonia와 Native AOT 전환 기록](docs/AVALONIA_AOT_MIGRATION.md) - [식탁보 AI Preview 구현 및 검증](docs/poc/managed-ai-runtime.md) - [스킬 마켓플레이스와 원격 카탈로그](docs/SKILL_MARKETPLACE.ko.md), [English](docs/SKILL_MARKETPLACE.md) +- [AppContainer 스킬 스크립트 실행 PoC와 평가 결과](tools/appcontainer-skill-poc/README.ko.md), [English](tools/appcontainer-skill-poc/README.md) - [Windows Sandbox 내부 Codex CLI와 Computer Use 탐색 기록](docs/poc/codex-sandbox-computer-use.ko.md), [English](docs/poc/codex-sandbox-computer-use.md) - [Windows Sandbox RDP 호스팅 실험과 검증 기록](docs/poc/rdp-sandbox.ko.md), [English](docs/poc/rdp-sandbox.md) diff --git a/docs/SKILL_MARKETPLACE.ko.md b/docs/SKILL_MARKETPLACE.ko.md index b3bb8f5f..63ebe6bf 100644 --- a/docs/SKILL_MARKETPLACE.ko.md +++ b/docs/SKILL_MARKETPLACE.ko.md @@ -2,6 +2,8 @@ 2026년 10월 25일 기준으로 이번 구현은 식탁보 `develop` 계열을 대상으로 합니다. 마켓플레이스는 TableClothCatalog의 별도 XML 스킬 카탈로그를 읽고 식탁보 AI 전용 Codex 프로필에 설치한 스킬을 관리합니다. 현재 공개한 Preview 릴리스에는 이번 변경을 포함하지 않습니다. +별도 `poc/appcontainer-skill-runner` 평가 분기는 활성 항목 6개의 실험용 카탈로그와 독립 [AppContainer 실행 PoC](../tools/appcontainer-skill-poc/README.ko.md)를 포함합니다. 아래 제품 설명은 기존 3개 항목의 등재 정책을 다룹니다. PoC의 중계기와 런타임은 채팅 창에 연결하지 않습니다. + 마켓플레이스에서 스킬 검색, 출처와 라이선스 열람, 설치와 업데이트, 로컬 스킬 관리를 제공합니다. 카탈로그에는 공개 상태나 검토 상태를 두지 않습니다. 주석 밖에 있는 스킬 항목을 설치 대상으로 표시합니다. 이 문서에서는 창의 사용 경로부터 카탈로그 계약, 설치와 업데이트, 로컬 저장 범위, 검증 범위 순서로 살펴보겠습니다. [English](SKILL_MARKETPLACE.md) diff --git a/docs/SKILL_MARKETPLACE.md b/docs/SKILL_MARKETPLACE.md index 4fac8a50..1beb4abe 100644 --- a/docs/SKILL_MARKETPLACE.md +++ b/docs/SKILL_MARKETPLACE.md @@ -2,6 +2,8 @@ As of October 25, 2026, the marketplace implementation targets the TableCloth `develop` line. It reads a separate XML skill catalog from TableClothCatalog and manages the skills in TableCloth AI's dedicated Codex profile. This work does not change the published Preview release. +The separate `poc/appcontainer-skill-runner` evaluation branch pins a six-entry experimental catalog and adds a standalone [AppContainer execution PoC](../tools/appcontainer-skill-poc/README.md). The production behavior described below retains the three-entry eligibility policy; the PoC does not connect its broker or runtimes to the chat window. + The marketplace combines skill discovery, source and license information, installation, updates, and local skill management. Catalog entries have no listing or review states. An uncommented entry is offered for installation. This guide explains the window, catalog contract, installation and updates, local storage, and verification boundaries in that order. [한국어](SKILL_MARKETPLACE.ko.md) diff --git a/external/TableClothCatalog b/external/TableClothCatalog index e6181516..66371151 160000 --- a/external/TableClothCatalog +++ b/external/TableClothCatalog @@ -1 +1 @@ -Subproject commit e6181516e07a35f61b02e5367a981fe00c19fa7f +Subproject commit 66371151f29ad22e06a1761f83e88cbc92cf180b diff --git a/src/TableCloth.ManagedAi.Test/SkillMarketplaceTests.cs b/src/TableCloth.ManagedAi.Test/SkillMarketplaceTests.cs index 6d1f6d29..29f62378 100644 --- a/src/TableCloth.ManagedAi.Test/SkillMarketplaceTests.cs +++ b/src/TableCloth.ManagedAi.Test/SkillMarketplaceTests.cs @@ -15,16 +15,27 @@ public sealed class SkillMarketplaceTests private const string Api = "https://api.github.com/repos/fixture/skills"; [TestMethod] - public void RepositoryCatalogMatchesRuntimeParserWithoutReviewState() + public void PoCEvaluationCatalogMatchesRuntimeParserWithoutReviewState() { var entries = SkillCatalogClient.Parse(File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "Fixtures", "SkillCatalog.xml"))); CollectionAssert.AreEquivalent(new[] { - "official-report-skillset", "tablecloth-certificate-expiry", "tablecloth-windows-sandbox" + "official-report-skillset", "tablecloth-certificate-expiry", "tablecloth-windows-sandbox", + "tablecloth-calculators", "bok-ecos-stats", "kosis-stats" }, entries.Select(x => x.Id).ToArray()); + // CanInstall checks technical file-installation requirements, not successful skill execution. Assert.IsTrue(entries.All(x => x.CanInstall)); Assert.IsTrue(entries.All(x => x.SourceRef.Length == 40)); - Assert.IsTrue(entries.All(x => x.Network == "none")); + Assert.AreEqual(4, entries.Count(x => x.Network == "none")); + Assert.AreEqual(2, entries.Count(x => x.Network == "required")); + foreach (var id in new[] { "bok-ecos-stats", "kosis-stats" }) + { + var entry = entries.Single(x => x.Id == id); + Assert.AreEqual("required", entry.Network); + Assert.AreEqual("https://github.com/NomaDamas/k-skill", entry.Repository); + Assert.AreEqual(id, entry.SourcePath); + Assert.AreEqual("0102e5133865f00886e37ac8cf39a49e2c7a3d39", entry.SourceRef); + } } [TestMethod] diff --git a/tools/appcontainer-skill-poc/.gitattributes b/tools/appcontainer-skill-poc/.gitattributes new file mode 100644 index 00000000..65ceafc2 --- /dev/null +++ b/tools/appcontainer-skill-poc/.gitattributes @@ -0,0 +1,2 @@ +# Keep pinned upstream fixtures byte-identical across Windows checkouts. +skills/** -text diff --git a/tools/appcontainer-skill-poc/Broker/EvaluationManifest.cs b/tools/appcontainer-skill-poc/Broker/EvaluationManifest.cs new file mode 100644 index 00000000..04ff3c9b --- /dev/null +++ b/tools/appcontainer-skill-poc/Broker/EvaluationManifest.cs @@ -0,0 +1,250 @@ +using System.Security.Cryptography; +using System.Text.Json; + +namespace TableCloth.AppContainerPoc; + +// This is a trusted, local evaluation registration file, never a model-supplied document. +public sealed record EvaluationManifest(string OwnedRoot, string PythonExecutable, string PythonSha256, + string? NodeExecutable, string? NodeSha256, IReadOnlyList Skills); + +public sealed record SkillRegistration(string SkillId, string Kind, string ScriptPath, string Sha256, + IReadOnlyList AllowedOperations, IReadOnlyList FixedArguments, bool RequiresNetwork); + +public sealed class BrokerValidationException(string message) : Exception(message); + +internal static class BrokerFiles +{ + internal const int MaximumManifestBytes = 131072; + internal const int MaximumInputBytes = 65536; + internal static readonly string[] CalculatorOperations = + [ + "percent.part", "percent.share", "percent.change", "percent.adjust", "percent.points", + "discount.single", "discount.sequential", "discount.reverse", "unit_price", "bill_split", + "interest.simple", "interest.compound", "savings.recurring", "savings.target", "loan", + "date.diff", "date.add", "date.age", "date.weekday", "time.convert", "unit.convert", "bmi" + ]; + + internal static async Task ReadManifestAsync(string path, CancellationToken token) + { + if (!Path.IsPathFullyQualified(path)) throw Invalid(); + path = Path.GetFullPath(path); + RequireLocalPath(path); + RejectReparsePoints(path); + var bytes = await ReadBoundedAsync(path, MaximumManifestBytes, token); + using var document = JsonDocument.Parse(bytes, new JsonDocumentOptions { MaxDepth = 16 }); + ValidateJson(document.RootElement); + var root = document.RootElement; + ObjectFields(root, ["ownedRoot", "pythonExecutable", "pythonSha256", "nodeExecutable", "nodeSha256", "skills"]); + var ownedRoot = Text(root, "ownedRoot", 240); + if (!Path.IsPathFullyQualified(ownedRoot) || ownedRoot.StartsWith(@"\\", StringComparison.Ordinal)) throw Invalid(); + ownedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(ownedRoot)); + RequireLocalPath(ownedRoot); + if (!Directory.Exists(ownedRoot) || string.Equals(ownedRoot, Path.GetPathRoot(ownedRoot), StringComparison.OrdinalIgnoreCase)) throw Invalid(); + RejectReparsePoints(ownedRoot); + RequireUnder(ownedRoot, path); + var python = ResolveFile(ownedRoot, Text(root, "pythonExecutable", 240)); + var pythonHash = Hash(root, "pythonSha256"); + string? node = OptionalText(root, "nodeExecutable", 240); + string? nodeHash = OptionalText(root, "nodeSha256", 64); + if ((node is null) != (nodeHash is null)) throw Invalid(); + if (node is not null) { node = ResolveFile(ownedRoot, node); ValidateHash(nodeHash!); } + if (!root.TryGetProperty("skills", out var list) || list.ValueKind != JsonValueKind.Array || list.GetArrayLength() is < 1 or > 16) throw Invalid(); + var skills = new List(); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in list.EnumerateArray()) + { + ObjectFields(item, ["skillId", "kind", "scriptPath", "sha256", "allowedOperations", "fixedArguments", "requiresNetwork"]); + var id = Text(item, "skillId", 64); + if (!names.Add(id) || id.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not ('-' or '_')) || !char.IsAsciiLetterOrDigit(id[0])) throw Invalid(); + var kind = Text(item, "kind", 32); + if (kind is not ("python-json-stdin" or "node-json-stdin" or "python-cli-fixed")) throw Invalid(); + var script = ResolveFile(ownedRoot, Text(item, "scriptPath", 240)); + var hash = Hash(item, "sha256"); + var operations = Strings(item, "allowedOperations", 32, 64); + var arguments = Strings(item, "fixedArguments", 32, 256, unique: false); + var network = false; + if (item.TryGetProperty("requiresNetwork", out var networkValue)) + { + if (networkValue.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) throw Invalid(); + network = networkValue.GetBoolean(); + } + if (kind == "python-json-stdin") + { + if (arguments.Count != 0 || operations.Any(x => !CalculatorOperations.Contains(x, StringComparer.Ordinal))) throw Invalid(); + if (operations.Count == 0) operations = Array.AsReadOnly(CalculatorOperations); + } + else if (kind == "node-json-stdin") + { + if (node is null || arguments.Count != 0 || operations.Count != 0) throw Invalid(); + } + else if (operations.Count != 0 || arguments.Count == 0) throw Invalid(); + skills.Add(new(id, kind, script, hash, operations, arguments, network)); + } + // Validate executable and script content before advertising any registration. + await using (await OpenVerifiedAsync(ownedRoot, python, pythonHash, token)) { } + if (node is not null) { await using var ignored = await OpenVerifiedAsync(ownedRoot, node, nodeHash!, token); } + foreach (var skill in skills) { await using var ignored = await OpenVerifiedAsync(ownedRoot, skill.ScriptPath, skill.Sha256, token); } + return new(ownedRoot, python, pythonHash, node, nodeHash, skills.AsReadOnly()); + } + + internal static async Task OpenVerifiedAsync(string root, string path, string expectedHash, CancellationToken token) + { + RequireUnder(root, path); + RejectReparsePoints(path); + // Keep the handle open through worker completion. On Windows this denies modification/deletion. + var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 65536, FileOptions.Asynchronous); + try + { + if (file.Length is <= 0 or > 268435456) throw Invalid(); + var hash = await SHA256.HashDataAsync(file, token); + if (!Convert.ToHexString(hash).Equals(expectedHash, StringComparison.OrdinalIgnoreCase)) throw Invalid(); + file.Position = 0; + return file; + } + catch { await file.DisposeAsync(); throw; } + } + + internal static string ResolveFile(string root, string supplied) + { + var path = Path.GetFullPath(supplied, root); + RequireUnder(root, path); + RejectReparsePoints(path); + if (!File.Exists(path)) throw Invalid(); + return path; + } + + internal static void RequireUnder(string root, string path) + { + var normalized = Path.GetFullPath(path); + RequireLocalPath(normalized); + if (!normalized.StartsWith(Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase) || normalized.Length > 240) throw Invalid(); + } + + private static void RequireLocalPath(string normalized) + { + // A drive separator is the only permitted colon. Reject ADS before any file is opened. + if (normalized.Length < 3 || normalized[1] != ':' || !char.IsAsciiLetter(normalized[0]) || + normalized.StartsWith(@"\\", StringComparison.Ordinal) || normalized.IndexOf(':', 2) >= 0) throw Invalid(); + } + + internal static void RejectReparsePoints(string path) + { + for (var current = Path.GetFullPath(path); !string.IsNullOrEmpty(current); current = Path.GetDirectoryName(current)) + if ((File.Exists(current) || Directory.Exists(current)) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) throw Invalid(); + } + + internal static void ValidateJson(JsonElement value) + { + var budget = 8192; + Visit(value, ref budget); + } + + internal static void ValidateCalculatorInput(JsonElement input, string operation) + { + var (required, optional) = operation switch + { + "percent.part" => (new[] { "total", "rate_percent" }, Array.Empty()), + "percent.share" => (new[] { "part", "total" }, Array.Empty()), + "percent.change" => (new[] { "before", "after" }, Array.Empty()), + "percent.adjust" => (new[] { "value", "rate_percent" }, Array.Empty()), + "percent.points" => (new[] { "before_percent", "after_percent" }, Array.Empty()), + "discount.single" or "discount.reverse" => (new[] { "price", "rate_percent" }, new[] { "places" }), + "discount.sequential" => (new[] { "price", "rates_percent" }, new[] { "places" }), + "unit_price" => (new[] { "total", "quantity" }, new[] { "per_quantity", "places" }), + "bill_split" => (new[] { "total", "weights" }, new[] { "places" }), + "interest.simple" => (new[] { "principal", "annual_rate_percent", "years" }, new[] { "places" }), + "interest.compound" => (new[] { "principal", "annual_rate_percent", "years" }, new[] { "places", "compounds_per_year" }), + "savings.recurring" => (new[] { "initial", "contribution", "annual_rate_percent", "periods", "periods_per_year" }, new[] { "timing", "places" }), + "savings.target" => (new[] { "initial", "target", "annual_rate_percent", "periods", "periods_per_year" }, new[] { "timing", "places" }), + "loan" => (new[] { "principal", "annual_rate_percent", "periods", "periods_per_year", "method" }, new[] { "schedule", "places" }), + "date.diff" => (new[] { "start", "end" }, new[] { "inclusion" }), + "date.add" => (new[] { "date" }, new[] { "years", "months", "days" }), + "date.age" => (new[] { "birth_date", "as_of" }, new[] { "leap_day" }), + "date.weekday" => (new[] { "date" }, Array.Empty()), + "time.convert" => (new[] { "datetime", "from_offset", "to_offset" }, Array.Empty()), + "unit.convert" => (new[] { "value", "from_unit", "to_unit" }, Array.Empty()), + "bmi" => (new[] { "weight_kg", "height_cm" }, Array.Empty()), + _ => throw Invalid() + }; + ObjectFields(input, ["operation", .. required, .. optional]); + foreach (var field in required) if (!input.TryGetProperty(field, out _)) throw Invalid(); + } + + private static void Visit(JsonElement value, ref int budget) + { + if (--budget < 0) throw Invalid(); + if (value.ValueKind == JsonValueKind.Object) + { + var keys = new HashSet(StringComparer.Ordinal); + foreach (var property in value.EnumerateObject()) + { + if (!keys.Add(property.Name)) throw Invalid(); + Visit(property.Value, ref budget); + } + } + else if (value.ValueKind == JsonValueKind.Array) + foreach (var item in value.EnumerateArray()) Visit(item, ref budget); + } + + internal static void ObjectFields(JsonElement value, string[] names) + { + if (value.ValueKind != JsonValueKind.Object || value.EnumerateObject().Any(x => !names.Contains(x.Name, StringComparer.Ordinal))) throw Invalid(); + } + + internal static string Text(JsonElement value, string name, int maximum) + => OptionalText(value, name, maximum) ?? throw Invalid(); + + private static string? OptionalText(JsonElement value, string name, int maximum) + { + if (!value.TryGetProperty(name, out var property) || property.ValueKind == JsonValueKind.Null) return null; + if (property.ValueKind != JsonValueKind.String) throw Invalid(); + var text = property.GetString()!; + if (string.IsNullOrWhiteSpace(text) || text.Length > maximum || text.Any(char.IsControl)) throw Invalid(); + return text; + } + + private static string Hash(JsonElement root, string name) + { + var value = Text(root, name, 64); + ValidateHash(value); + return value; + } + + private static void ValidateHash(string value) + { + if (value.Length != 64 || !value.All(char.IsAsciiHexDigit)) throw Invalid(); + } + + private static IReadOnlyList Strings(JsonElement value, string name, int maximumCount, int maximumLength, bool unique = true) + { + if (!value.TryGetProperty(name, out var list)) return Array.Empty(); + if (list.ValueKind != JsonValueKind.Array || list.GetArrayLength() > maximumCount) throw Invalid(); + var result = new List(); + foreach (var item in list.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String) throw Invalid(); + var text = item.GetString()!; + if (string.IsNullOrEmpty(text) || text.Length > maximumLength || text.Any(char.IsControl)) throw Invalid(); + result.Add(text); + } + if (unique && result.Distinct(StringComparer.Ordinal).Count() != result.Count) throw Invalid(); + return result.AsReadOnly(); + } + + private static async Task ReadBoundedAsync(string path, int maximum, CancellationToken token) + { + await using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, FileOptions.Asynchronous); + using var buffer = new MemoryStream(); + var chunk = new byte[8192]; + int count; + while ((count = await file.ReadAsync(chunk, token)) != 0) + { + if (buffer.Length + count > maximum) throw Invalid(); + buffer.Write(chunk, 0, count); + } + return buffer.ToArray(); + } + + internal static BrokerValidationException Invalid() => new("Invalid evaluation registration or input."); +} diff --git a/tools/appcontainer-skill-poc/Broker/README.md b/tools/appcontainer-skill-poc/Broker/README.md new file mode 100644 index 00000000..f306de68 --- /dev/null +++ b/tools/appcontainer-skill-poc/Broker/README.md @@ -0,0 +1,69 @@ +# Fixed skill execution broker + +This original, BCL-only evaluation implementation exposes reviewed local scripts through a bounded stdio MCP server. It does not change TableCloth's production profile, shell features, global executable paths, or authentication. The native `AppContainerProcessRunner` supplies the isolation boundary; MCP supplies only the communication protocol. + +## Trusted registration file + +The caller supplies an absolute manifest path inside an existing, non-root `ownedRoot` directory. Executable and script paths may be relative to that directory or absolute paths inside it. The broker rejects paths outside the directory, UNC paths, alternate data streams, reparse ancestors, unknown manifest fields, duplicate JSON keys, and invalid hashes. The manifest is limited to 128 KiB and 16 skills. Each registered interpreter and script has a SHA-256 digest; the broker checks those files during loading and again before execution. It holds read-only file handles until the worker finishes to deny changes to those files on Windows. The caller remains responsible for the trusted manifest and the remaining runtime dependency files. + +The following shape uses placeholder digests that the preparer replaces with actual SHA-256 values: + +```json +{ + "ownedRoot": "D:\\Projects\\TableCloth\\Artifacts\\appcontainer-skill-poc", + "pythonExecutable": "runtime/python/python.exe", + "pythonSha256": "<64 hexadecimal characters>", + "nodeExecutable": "runtime/node/node.exe", + "nodeSha256": "<64 hexadecimal characters>", + "skills": [ + { + "skillId": "tablecloth-calculators", + "kind": "python-json-stdin", + "scriptPath": "skills/tablecloth-calculators/scripts/calculate.py", + "sha256": "<64 hexadecimal characters>", + "allowedOperations": ["percent.part", "discount.single", "loan", "date.add", "time.convert", "bmi"], + "requiresNetwork": false + } + ] +} +``` + +`pythonExecutable` and `pythonSha256` are required. Node fields may both be omitted or null when no Node fixture is registered. `allowedOperations`, `fixedArguments`, and `requiresNetwork` are optional; network defaults to denied. Python calculator registrations default to the original calculator's 22 known operations when the list is omitted. The broker additionally rejects missing or unknown calculator fields before invoking the worker. The original calculator remains responsible for arithmetic validation and result rounding. + +## Fixed execution kinds + +| Kind | Tool input | Fixed worker invocation | +| --- | --- | --- | +| `python-json-stdin` | Calculator JSON object with an allowed `operation` | Registered Python, `-I -S -B -X utf8`, registered script, JSON on stdin | +| `python-cli-fixed` | Empty `{}` only | Registered Python, `-I -S -B -X utf8`, registered script, manifest `fixedArguments` | +| `node-json-stdin` | Finite JSON numbers `a` and `b` only | Registered Node, `--preserve-symlinks-main`, registered addition fixture, JSON on stdin | + +The model cannot select an executable, script path, expression, interpreter flags, working directory, or CLI arguments. Fixed CLI arguments belong to the trusted registration file. A reviewed KOSIS dry-run can register `search --query 인구 --result-count 1 --json --dry-run`. An actual BOK query registers `requiresNetwork=true` and remains blocked here. No network flag supplied in tool input can override that decision. + +Python's isolated mode ignores `PYTHONUTF8` from the environment. The broker therefore fixes `-X utf8` on the interpreter command line for every Python kind so Korean stdin and output use the same UTF-8 encoding as the transport. + +The fixed Node option avoids main-file realpath traversal of volume ancestors outside the granted directories. It does not relax registration path checks: reparse paths remain rejected and the registered executable and script hashes remain mandatory. The caller cannot add, remove, or override the option through tool input. + +Every request uses a new private work directory under `ownedRoot/broker-work`. The native runner receives read-only directories for the interpreter and script, one read/write work directory, `AllowNetwork=false`, a 15-second timeout, a 256 MiB memory limit, a 64 KiB combined output limit, and a one-process limit. After the awaited worker finishes or cancellation cleanup completes, the broker removes only its owned work directory and refuses reparse-based cleanup. A failed cleanup returns an error instead of a successful result. + +Internet-capability experiments performed directly by the evaluation harness are separate from this broker. Neither a successful dry-run nor an unrestricted internet-capability experiment establishes a domain allowlist, a safe credential integration, or production eligibility. + +## MCP transport and results + +`SkillMcpBroker.RunAsync(manifestPath, token)` uses only JSON-RPC on stdout. Fixed, content-free failure messages go to stderr. It supports `initialize`, `notifications/initialized`, `ping`, `tools/list`, and `tools/call`, following the [MCP stdio transport](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports), [lifecycle](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle), and [tool result](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) contracts. Notifications never execute a tool and never receive a response. Initialization negotiates supported protocol versions through `2025-06-18`. + +Each tool has its fixed skill ID as its name. `tools/call.params.arguments` contains the operation input directly. For example, the calculator call uses the following request after initialization: + +```json +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"tablecloth-calculators","arguments":{"operation":"discount.single","price":100,"rate_percent":20}}} +``` + +A tool result contains a JSON text block with `status`, `exitCode`, `stdout`, `stderr`, `wasAppContainer`, `timedOut`, `outputLimited`, and `failure`. Only a successful exit with AppContainer evidence and no timeout or output limit produces `status="completed"`; calculator results also require a valid JSON object with `ok=true`. Network policy and invalid-input results set `isError=true` without starting a worker. Process failures and bounded worker output remain distinguishable from JSON-RPC protocol errors. + +Input lines are limited to 128 KiB, operation JSON to 64 KiB, response messages to 1 MiB, nesting to 32 levels, and a session to 1,024 input messages. Oversized lines and invalid UTF-8 produce a protocol error and terminate the transport without reading an unbounded remainder. Duplicate keys are rejected. Output text is JSON-escaped within the response, so a worker cannot inject a protocol message through stdout. + +## Offline checks and native evidence + +`BrokerSelfChecks.RunAsync(ownedRoot, token)` returns named fixture checks with `Passed` and a content-free `Failure`. It uses ordinary fixture files and a fake executor, never a native worker. Checks cover registration paths and hashes, argument and code injection, duplicate and oversized inputs, network denial, output bounds, required AppContainer evidence, JSON-RPC lifecycle and notifications, invalid UTF-8, cancellation, and work-directory cleanup. + +`SkillExecutionRegistry.LoadAsync(manifestPath, executor, token)` injects the offline executor. `SkillMcpBroker.RunAsync(registry, input, output, token)` injects transport streams. These seams are not exposed through the manifest or MCP protocol. Their passing results do not establish actual AppContainer execution, operating-system denial, a model's tool selection, or external service access. The evaluation harness performs those checks separately with the real native runner. diff --git a/tools/appcontainer-skill-poc/Broker/SelfChecks.cs b/tools/appcontainer-skill-poc/Broker/SelfChecks.cs new file mode 100644 index 00000000..f04b5837 --- /dev/null +++ b/tools/appcontainer-skill-poc/Broker/SelfChecks.cs @@ -0,0 +1,287 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace TableCloth.AppContainerPoc; + +public sealed record BrokerSelfCheckResult(string Name, bool Passed, string? Failure = null); + +/// Offline fixture checks only. Fake executor evidence is never native isolation evidence. +public static class BrokerSelfChecks +{ + public static async Task> RunAsync(string ownedRoot, CancellationToken token) + { + ownedRoot = Path.GetFullPath(ownedRoot); + BrokerFiles.RejectReparsePoints(ownedRoot); + var root = Path.Combine(ownedRoot, "broker-selfcheck-" + Guid.NewGuid().ToString("N")); + BrokerFiles.RequireUnder(ownedRoot, root); + Directory.CreateDirectory(Path.Combine(root, "runtime")); + Directory.CreateDirectory(Path.Combine(root, "scripts")); + var python = Path.Combine(root, "runtime", "python.exe"); + var node = Path.Combine(root, "runtime", "node.exe"); + var script = Path.Combine(root, "scripts", "fixture.py"); + var manifest = Path.Combine(root, "manifest.json"); + await File.WriteAllTextAsync(python, "offline fixture; never executed", token); + await File.WriteAllTextAsync(node, "offline Node fixture; never executed", token); + await File.WriteAllTextAsync(script, "# offline fixture; never executed\n", token); + var original = await File.ReadAllBytesAsync(script, token); + var checks = new List(); + var calls = 0; + SandboxRunRequest? captured = null; + Task Fake(SandboxRunRequest request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); calls++; captured = request; + return Task.FromResult(new SandboxRunResult(0, "{\"ok\":true,\"result\":{\"final\":\"80.00\"}}", "", false, false, true)); + } + async Task Check(string name, Func action) + { + token.ThrowIfCancellationRequested(); + try { await action(); checks.Add(new(name, true)); } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch { checks.Add(new(name, false, "fixture assertion failed")); } + } + async Task Registry(bool network = false, string kind = "python-json-stdin") + { + await WriteManifest(manifest, root, python, script, network: network, kind: kind, token: token); + return await SkillExecutionRegistry.LoadAsync(manifest, Fake, token); + } + using var valid = JsonDocument.Parse("{\"operation\":\"discount.single\",\"price\":100,\"rate_percent\":20}"); + try + { + var registry = await Registry(); + await Check("fixed-worker-request-and-cleanup", async () => + { + var result = await registry.ExecuteAsync("fixture", valid.RootElement, token); + Require(!result.IsError && result.WasAppContainer && calls == 1); + Require(captured is not null && !captured.AllowNetwork && captured.Executable == python); + Require(captured!.Arguments.SequenceEqual(new[] { "-I", "-S", "-B", "-X", "utf8", script })); + Require(captured.ReadOnlyDirectories.Count == 2 && captured.ReadWriteDirectories.SequenceEqual(new[] { captured.WorkingDirectory })); + Require(!Directory.Exists(captured.WorkingDirectory)); + }); + await Check("tool-input-cannot-add-command-code-or-path", async () => + { + var before = calls; + foreach (var extra in new[] { "command", "code", "scriptPath", "executable", "arguments" }) + { + using var input = JsonDocument.Parse("{\"operation\":\"discount.single\",\"price\":100,\"rate_percent\":20,\"" + extra + "\":\"untrusted\"}"); + Require((await registry.ExecuteAsync("fixture", input.RootElement, token)).Failure == "invalid-input"); + } + using var expression = JsonDocument.Parse("{\"operation\":\"eval\",\"code\":\"1+1\"}"); + Require((await registry.ExecuteAsync("fixture", expression.RootElement, token)).IsError && calls == before); + }); + await Check("duplicate-input-properties", async () => + { + var before = calls; + using var input = JsonDocument.Parse("{\"operation\":\"discount.single\",\"price\":100,\"price\":0,\"rate_percent\":20}"); + Require((await registry.ExecuteAsync("fixture", input.RootElement, token)).Failure == "invalid-input" && calls == before); + }); + await Check("oversized-tool-input", async () => + { + var before = calls; + using var input = JsonDocument.Parse("{\"operation\":\"discount.single\",\"price\":\"" + new string('1', 65536) + "\",\"rate_percent\":20}"); + Require((await registry.ExecuteAsync("fixture", input.RootElement, token)).IsError && calls == before); + }); + await Check("unknown-skill-never-executes", async () => + { + var before = calls; + Require((await registry.ExecuteAsync("../other", valid.RootElement, token)).Failure == "unknown-skill" && calls == before); + }); + await Check("network-registration-never-executes", async () => + { + var blocked = await Registry(network: true, kind: "python-cli-fixed"); + using var empty = JsonDocument.Parse("{}"); + var before = calls; + Require((await blocked.ExecuteAsync("fixture", empty.RootElement, token)).Failure == "network-policy" && calls == before); + }); + await Check("fixed-cli-has-no-model-arguments", async () => + { + var fixedCli = await Registry(kind: "python-cli-fixed"); + using var empty = JsonDocument.Parse("{}"); + Require(!(await fixedCli.ExecuteAsync("fixture", empty.RootElement, token)).IsError); + Require(captured!.Arguments.SequenceEqual(new[] { "-I", "-S", "-B", "-X", "utf8", script, "search", "--dry-run" })); + var before = calls; + Require((await fixedCli.ExecuteAsync("fixture", valid.RootElement, token)).IsError && calls == before); + }); + await Check("node-fixture-has-fixed-main-option-and-numeric-input", async () => + { + var nodeRegistry = await Registry(kind: "node-json-stdin"); + using var input = JsonDocument.Parse("{\"a\":1,\"b\":2}"); + Require(!(await nodeRegistry.ExecuteAsync("fixture", input.RootElement, token)).IsError); + Require(captured!.Executable == node && !captured.AllowNetwork); + Require(captured.Arguments.SequenceEqual(new[] { "--preserve-symlinks-main", script })); + var before = calls; + using var injected = JsonDocument.Parse("{\"a\":1,\"b\":2,\"arguments\":[\"--eval\",\"untrusted\"]}"); + Require((await nodeRegistry.ExecuteAsync("fixture", injected.RootElement, token)).IsError && calls == before); + }); + await Check("changed-script-hash-is-rechecked", async () => + { + var verified = await Registry(); var before = calls; + await File.AppendAllTextAsync(script, "# changed\n", token); + try { Require((await verified.ExecuteAsync("fixture", valid.RootElement, token)).Failure == "registration-integrity" && calls == before); } + finally { await File.WriteAllBytesAsync(script, original, token); } + }); + await Check("manifest-rejects-outside-script-path", async () => + { + await WriteManifest(manifest, root, python, script, scriptPath: "../outside.py", token: token); + await MustReject(() => SkillExecutionRegistry.LoadAsync(manifest, Fake, token)); + }); + await Check("manifest-rejects-outside-interpreter", async () => + { + await WriteManifest(manifest, root, python, script, pythonPath: "../outside.exe", token: token); + await MustReject(() => SkillExecutionRegistry.LoadAsync(manifest, Fake, token)); + }); + await Check("alternate-data-stream-paths-rejected-before-io", async () => + { + // Normal drive colons remain valid. Do not create, open, or probe an actual ADS. + BrokerFiles.RequireUnder(root, script); + BrokerFiles.RequireUnder(root, python); + foreach (var path in new[] { script + ":payload", script + ":payload:$DATA", python + ":payload" }) + { + var rejected = false; + try { BrokerFiles.RequireUnder(root, path); } + catch (BrokerValidationException) { rejected = true; } + Require(rejected); + } + await WriteManifest(manifest, root, python, script, scriptPath: "scripts/fixture.py:payload", token: token); + await MustReject(() => SkillExecutionRegistry.LoadAsync(manifest, Fake, token)); + }); + await Check("manifest-rejects-incorrect-script-hash", async () => + { + await WriteManifest(manifest, root, python, script, hash: new string('0', 64), token: token); + await MustReject(() => SkillExecutionRegistry.LoadAsync(manifest, Fake, token)); + }); + await Check("manifest-rejects-duplicate-properties", async () => + { + await WriteManifest(manifest, root, python, script, token: token); + var text = await File.ReadAllTextAsync(manifest, token); + await File.WriteAllTextAsync(manifest, "{\"ownedRoot\":\"duplicate\"," + text[1..], token); + await MustReject(() => SkillExecutionRegistry.LoadAsync(manifest, Fake, token)); + }); + await Check("worker-requires-appcontainer-evidence", async () => + { + await WriteManifest(manifest, root, python, script, token: token); + var outside = await SkillExecutionRegistry.LoadAsync(manifest, + (_, _) => Task.FromResult(new SandboxRunResult(0, "not trusted", "", false, false, false)), token); + var result = await outside.ExecuteAsync("fixture", valid.RootElement, token); + Require(result.Failure == "appcontainer-required" && result.Stdout == ""); + }); + await Check("worker-output-is-bounded", async () => + { + var large = await SkillExecutionRegistry.LoadAsync(manifest, + (_, _) => Task.FromResult(new SandboxRunResult(0, new string('x', 65537), "", false, false, true)), token); + var result = await large.ExecuteAsync("fixture", valid.RootElement, token); + Require(result.OutputLimited && result.Stdout == "" && result.IsError); + }); + registry = await Registry(); + await Check("stdio-jsonrpc-and-tool-contract", async () => + { + var requests = string.Join('\n', + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"offline-fixture\",\"version\":\"1\"}}}", + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}", + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}", + "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\"}", + "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"fixture\",\"arguments\":{\"operation\":\"discount.single\",\"price\":100,\"rate_percent\":20}}}", + "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"unknown\"}}", + "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"fixture\",\"arguments\":{\"operation\":\"exec\",\"code\":\"untrusted\"}}}", + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"unimplemented\"}", + "{\"jsonrpc\":\"2.0\",\"id\":8,\"id\":9,\"method\":\"ping\"}", + "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\",\"params\":{\"name\":\"fixture\",\"arguments\":[]}}", + "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"fixture\"}}") + "\n"; + var before = calls; + var (exit, responses) = await Exchange(registry, Encoding.UTF8.GetBytes(requests), token); + Require(exit == 0 && responses.Count == 9 && calls == before + 1); + foreach (var response in responses) Require(response.GetProperty("jsonrpc").GetString() == "2.0"); + var tools = responses[2].GetProperty("result").GetProperty("tools"); + Require(tools.GetArrayLength() == 1 && tools[0].GetProperty("inputSchema").GetProperty("properties").TryGetProperty("price", out _)); + Require(!responses[3].GetProperty("result").GetProperty("isError").GetBoolean()); + Require(responses[4].GetProperty("error").GetProperty("code").GetInt32() == -32602); + Require(responses[5].GetProperty("result").GetProperty("isError").GetBoolean()); + Require(responses[7].GetProperty("id").ValueKind == JsonValueKind.Null); + }); + await Check("stdio-oversize-ends-with-protocol-error", async () => + { + var before = calls; + var (exit, responses) = await Exchange(registry, Encoding.UTF8.GetBytes(new string('x', 131073) + "\n"), token); + Require(exit == 2 && responses.Count == 1 && responses[0].GetProperty("error").GetProperty("code").GetInt32() == -32700 && calls == before); + }); + await Check("stdio-invalid-utf8-has-no-content-leak", async () => + { + var (exit, responses) = await Exchange(registry, new byte[] { 0xff, (byte)'\n' }, token); + Require(exit == 2 && responses.Single().GetProperty("error").GetProperty("code").GetInt32() == -32700); + }); + await Check("cancellation-awaits-fake-worker-and-cleans-work", async () => + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var stopped = false; + string? runningWork = null; + var pendingRegistry = await SkillExecutionRegistry.LoadAsync(manifest, async (request, ct) => + { + runningWork = request.WorkingDirectory; started.SetResult(); + try { await Task.Delay(Timeout.Infinite, ct); } + finally { await Task.Yield(); stopped = true; } + return new SandboxRunResult(0, "", "", false, false, true); + }, token); + using var stop = CancellationTokenSource.CreateLinkedTokenSource(token); + var pending = pendingRegistry.ExecuteAsync("fixture", valid.RootElement, stop.Token); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5), token); + await stop.CancelAsync(); + try { await pending; throw new InvalidOperationException(); } + catch (OperationCanceledException) { Require(stopped && runningWork is not null && !Directory.Exists(runningWork)); } + }); + } + finally + { + BrokerFiles.RequireUnder(ownedRoot, root); + BrokerFiles.RejectReparsePoints(root); + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + return checks.AsReadOnly(); + } + + private static async Task<(int ExitCode, List Responses)> Exchange(SkillExecutionRegistry registry, byte[] bytes, CancellationToken token) + { + using var input = new MemoryStream(bytes); using var output = new MemoryStream(); + var exit = await SkillMcpBroker.RunAsync(registry, input, output, token); + var responses = new List(); + foreach (var line in Encoding.UTF8.GetString(output.ToArray()).Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { using var json = JsonDocument.Parse(line); responses.Add(json.RootElement.Clone()); } + return (exit, responses); + } + + private static async Task WriteManifest(string path, string root, string python, string script, + string? scriptPath = null, string? pythonPath = null, string? hash = null, bool network = false, + string kind = "python-json-stdin", CancellationToken token = default) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); writer.WriteString("ownedRoot", root); + writer.WriteString("pythonExecutable", pythonPath ?? "runtime/python.exe"); + writer.WriteString("pythonSha256", Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(python, token)))); + if (kind == "node-json-stdin") + { + writer.WriteString("nodeExecutable", "runtime/node.exe"); + writer.WriteString("nodeSha256", Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(Path.Combine(root, "runtime", "node.exe"), token)))); + } + writer.WritePropertyName("skills"); writer.WriteStartArray(); writer.WriteStartObject(); + writer.WriteString("skillId", "fixture"); writer.WriteString("kind", kind); writer.WriteString("scriptPath", scriptPath ?? "scripts/fixture.py"); + writer.WriteString("sha256", hash ?? Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(script, token)))); + writer.WriteBoolean("requiresNetwork", network); + if (kind == "python-cli-fixed") + { + writer.WritePropertyName("fixedArguments"); writer.WriteStartArray(); writer.WriteStringValue("search"); writer.WriteStringValue("--dry-run"); writer.WriteEndArray(); + } + writer.WriteEndObject(); writer.WriteEndArray(); writer.WriteEndObject(); + } + await File.WriteAllBytesAsync(path, stream.ToArray(), token); + } + + private static async Task MustReject(Func> action) + { + try { await action(); } + catch (Exception error) when (error is BrokerValidationException or JsonException) { return; } + throw new InvalidOperationException(); + } + + private static void Require(bool condition) { if (!condition) throw new InvalidOperationException(); } +} diff --git a/tools/appcontainer-skill-poc/Broker/SkillExecutionRegistry.cs b/tools/appcontainer-skill-poc/Broker/SkillExecutionRegistry.cs new file mode 100644 index 00000000..317bd40a --- /dev/null +++ b/tools/appcontainer-skill-poc/Broker/SkillExecutionRegistry.cs @@ -0,0 +1,157 @@ +using System.Text; +using System.Text.Json; + +namespace TableCloth.AppContainerPoc; + +public sealed record SkillExecutionResult(string Status, int? ExitCode, string Stdout, string Stderr, + bool WasAppContainer, bool TimedOut, bool OutputLimited, string? Failure) +{ + public bool IsError => Status != "completed"; +} + +public sealed class SkillExecutionRegistry +{ + private readonly EvaluationManifest _manifest; + private readonly Func> _execute; + private readonly SemaphoreSlim _operation = new(1, 1); + public IReadOnlyList Skills => _manifest.Skills; + + private SkillExecutionRegistry(EvaluationManifest manifest, + Func>? execute) + { + _manifest = manifest; + _execute = execute ?? new AppContainerProcessRunner(manifest.OwnedRoot).RunAsync; + } + + public static Task LoadAsync(string manifestPath, CancellationToken token) + => LoadAsync(manifestPath, null, token); + + // Executor injection is an offline test seam, not part of MCP input or production configuration. + public static async Task LoadAsync(string manifestPath, + Func>? execute, CancellationToken token) + => new(await BrokerFiles.ReadManifestAsync(manifestPath, token), execute); + + public async Task ExecuteAsync(string skillId, JsonElement input, CancellationToken token) + { + var skill = Skills.FirstOrDefault(x => string.Equals(x.SkillId, skillId, StringComparison.Ordinal)); + if (skill is null) return Blocked("unknown-skill"); + try { ValidateInput(skill, input); } + catch (Exception error) when (error is BrokerValidationException or InvalidOperationException) + { return Blocked("invalid-input"); } + // Internet capability is never granted by this broker, including trusted fixed CLI registrations. + if (skill.RequiresNetwork) return Blocked("network-policy"); + await _operation.WaitAsync(token); + string? work = null; + SkillExecutionResult result = Blocked("worker-unavailable"); + var cleanupFailed = false; + try + { + var executable = skill.Kind == "node-json-stdin" ? _manifest.NodeExecutable! : _manifest.PythonExecutable; + var executableHash = skill.Kind == "node-json-stdin" ? _manifest.NodeSha256! : _manifest.PythonSha256; + await using var executableLock = await BrokerFiles.OpenVerifiedAsync(_manifest.OwnedRoot, executable, executableHash, token); + await using var scriptLock = await BrokerFiles.OpenVerifiedAsync(_manifest.OwnedRoot, skill.ScriptPath, skill.Sha256, token); + var workRoot = Path.Combine(_manifest.OwnedRoot, "broker-work"); + BrokerFiles.RequireUnder(_manifest.OwnedRoot, workRoot); + BrokerFiles.RejectReparsePoints(workRoot); + Directory.CreateDirectory(workRoot); + BrokerFiles.RejectReparsePoints(workRoot); + work = Path.Combine(workRoot, Guid.NewGuid().ToString("N")); + BrokerFiles.RequireUnder(_manifest.OwnedRoot, work); + Directory.CreateDirectory(work); + BrokerFiles.RejectReparsePoints(work); + var arguments = new List(); + if (skill.Kind == "node-json-stdin") arguments.Add("--preserve-symlinks-main"); + else arguments.AddRange(["-I", "-S", "-B", "-X", "utf8"]); + arguments.Add(skill.ScriptPath); + if (skill.Kind == "python-cli-fixed") arguments.AddRange(skill.FixedArguments); + var request = new SandboxRunRequest(executable, arguments.AsReadOnly(), work, + skill.Kind == "python-cli-fixed" ? "" : input.GetRawText() + "\n", + Array.AsReadOnly(new[] { Path.GetDirectoryName(executable)!, Path.GetDirectoryName(skill.ScriptPath)! }.Distinct(StringComparer.OrdinalIgnoreCase).ToArray()), + Array.AsReadOnly(new[] { work }), AllowNetwork: false); + var outcome = await _execute(request, token); + var outputSize = Encoding.UTF8.GetByteCount(outcome.Stdout) + (long)Encoding.UTF8.GetByteCount(outcome.Stderr); + if (outcome.OutputLimited || outputSize > request.MaxOutputBytes) + result = new("failed", outcome.ExitCode, "", "", outcome.WasAppContainer, outcome.TimedOut, true, "output-limit"); + else if (!outcome.WasAppContainer) + result = new("blocked", outcome.ExitCode, "", "", false, outcome.TimedOut, false, "appcontainer-required"); + else + { + var completed = outcome.ExitCode == 0 && !outcome.TimedOut && outcome.Failure is null; + var failure = outcome.TimedOut ? "timeout" : outcome.Failure is not null ? "worker-failure" : outcome.ExitCode != 0 ? "worker-exit" : null; + if (completed && skill.Kind == "python-json-stdin" && !ValidCalculatorOutput(outcome.Stdout)) + { completed = false; failure = "invalid-worker-output"; } + result = new(completed ? "completed" : "failed", outcome.ExitCode, outcome.Stdout, outcome.Stderr, + true, outcome.TimedOut, false, failure); + } + } + catch (OperationCanceledException) { throw; } + catch (BrokerValidationException) { result = Blocked("registration-integrity"); } + catch { result = Blocked("worker-unavailable"); } + finally + { + try { if (work is not null) DeleteWorkDirectory(work); } + catch { cleanupFailed = true; } + finally { _operation.Release(); } + } + return cleanupFailed ? result with { Status = "failed", Failure = "cleanup-failed" } : result; + } + + private static void ValidateInput(SkillRegistration skill, JsonElement input) + { + if (input.ValueKind != JsonValueKind.Object || Encoding.UTF8.GetByteCount(input.GetRawText()) > BrokerFiles.MaximumInputBytes) throw BrokerFiles.Invalid(); + BrokerFiles.ValidateJson(input); + if (skill.Kind == "python-cli-fixed") + { + if (input.EnumerateObject().Any()) throw BrokerFiles.Invalid(); + } + else if (skill.Kind == "python-json-stdin") + { + var operation = BrokerFiles.Text(input, "operation", 64); + if (!skill.AllowedOperations.Contains(operation, StringComparer.Ordinal)) throw BrokerFiles.Invalid(); + BrokerFiles.ValidateCalculatorInput(input, operation); + } + else + { + BrokerFiles.ObjectFields(input, ["a", "b"]); + foreach (var field in new[] { "a", "b" }) + if (!input.TryGetProperty(field, out var number) || number.ValueKind != JsonValueKind.Number || + !number.TryGetDouble(out var parsed) || !double.IsFinite(parsed)) throw BrokerFiles.Invalid(); + } + } + + private static bool ValidCalculatorOutput(string text) + { + try + { + using var result = JsonDocument.Parse(text, new JsonDocumentOptions { MaxDepth = 32 }); + BrokerFiles.ValidateJson(result.RootElement); + return result.RootElement.ValueKind == JsonValueKind.Object && result.RootElement.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.True; + } + catch (Exception error) when (error is JsonException or BrokerValidationException) { return false; } + } + + private void DeleteWorkDirectory(string directory) + { + // A worker cannot turn cleanup into a recursive operation on an unowned/reparse target. + BrokerFiles.RequireUnder(_manifest.OwnedRoot, directory); + BrokerFiles.RejectReparsePoints(directory); + if (!Directory.Exists(directory)) return; + var pending = new Stack(); + pending.Push(directory); + var count = 0; + while (pending.TryPop(out var current)) + { + BrokerFiles.RejectReparsePoints(current); + foreach (var child in Directory.EnumerateFileSystemEntries(current)) + { + if (++count > 4096) throw BrokerFiles.Invalid(); + BrokerFiles.RequireUnder(directory, child); + if ((File.GetAttributes(child) & FileAttributes.ReparsePoint) != 0) throw BrokerFiles.Invalid(); + if (Directory.Exists(child)) pending.Push(child); + } + } + Directory.Delete(directory, recursive: true); + } + + private static SkillExecutionResult Blocked(string reason) => new("blocked", null, "", "", false, false, false, reason); +} diff --git a/tools/appcontainer-skill-poc/Broker/SkillMcpBroker.cs b/tools/appcontainer-skill-poc/Broker/SkillMcpBroker.cs new file mode 100644 index 00000000..e99a3b37 --- /dev/null +++ b/tools/appcontainer-skill-poc/Broker/SkillMcpBroker.cs @@ -0,0 +1,330 @@ +using System.Text; +using System.Text.Json; + +namespace TableCloth.AppContainerPoc; + +/// Bounded stdio MCP transport. Isolation comes from the native runner, not MCP. +public static class SkillMcpBroker +{ + private const int MaximumRequestBytes = 131072; + private const int MaximumResponseBytes = 1048576; + private const string ProtocolVersion = "2025-06-18"; + + public static async Task RunAsync(string manifestPath, CancellationToken token) + { + try + { + var registry = await SkillExecutionRegistry.LoadAsync(manifestPath, token); + return await RunAsync(registry, Console.OpenStandardInput(), Console.OpenStandardOutput(), token); + } + catch (OperationCanceledException) { return 130; } + catch + { + await Console.Error.WriteLineAsync("broker: registration or transport failed."); + return 2; + } + } + + // Stream injection allows offline transport tests without altering the process console or starting workers. + public static async Task RunAsync(SkillExecutionRegistry registry, Stream input, Stream output, CancellationToken token) + { + var reader = new BoundedLines(input, MaximumRequestBytes); + var initialized = false; + var ready = false; + for (var count = 0; count < 1024; count++) + { + var line = await reader.ReadAsync(token); + if (line.End) return 0; + if (line.Invalid) + { + await SendErrorAsync(output, null, -32700, "Invalid or oversized JSON message.", token); + return 2; + } + JsonDocument document; + try + { + document = JsonDocument.Parse(line.Text!, new JsonDocumentOptions { MaxDepth = 32 }); + BrokerFiles.ValidateJson(document.RootElement); + } + catch (Exception error) when (error is JsonException or BrokerValidationException) + { + await SendErrorAsync(output, null, -32700, "Invalid JSON message.", token); + continue; + } + using (document) + { + var root = document.RootElement; + JsonElement? id = null; + string method; + JsonElement parameters = default; + try + { + BrokerFiles.ObjectFields(root, ["jsonrpc", "id", "method", "params"]); + if (BrokerFiles.Text(root, "jsonrpc", 3) != "2.0") throw BrokerFiles.Invalid(); + method = BrokerFiles.Text(root, "method", 80); + if (root.TryGetProperty("id", out var requestId)) + { + if (requestId.ValueKind == JsonValueKind.String && requestId.GetString()!.Length <= 128 || + requestId.ValueKind == JsonValueKind.Number && requestId.TryGetInt64(out _)) id = requestId; + else throw BrokerFiles.Invalid(); + } + if (root.TryGetProperty("params", out parameters) && parameters.ValueKind != JsonValueKind.Object) throw BrokerFiles.Invalid(); + } + catch (BrokerValidationException) + { + await SendErrorAsync(output, null, -32600, "Invalid JSON-RPC request.", token); + continue; + } + if (id is null) + { + if (method == "notifications/initialized" && initialized) ready = true; + // JSON-RPC notifications never receive a response; no notification can execute a tool. + continue; + } + try + { + if (method == "initialize") + { + if (initialized) throw new RpcFailure(-32600, "Already initialized."); + if (parameters.ValueKind != JsonValueKind.Object) throw new RpcFailure(-32602, "Invalid initialization parameters."); + var requested = BrokerFiles.Text(parameters, "protocolVersion", 32); + if (!parameters.TryGetProperty("capabilities", out var capabilities) || capabilities.ValueKind != JsonValueKind.Object || + !parameters.TryGetProperty("clientInfo", out var clientInfo) || clientInfo.ValueKind != JsonValueKind.Object) + throw new RpcFailure(-32602, "Invalid initialization parameters."); + _ = BrokerFiles.Text(clientInfo, "name", 128); + _ = BrokerFiles.Text(clientInfo, "version", 64); + var version = requested is "2024-11-05" or "2025-03-26" or ProtocolVersion ? requested : ProtocolVersion; + await SendResultAsync(output, id.Value, writer => + { + writer.WriteStartObject(); + writer.WriteString("protocolVersion", version); + writer.WritePropertyName("capabilities"); writer.WriteStartObject(); + writer.WritePropertyName("tools"); writer.WriteStartObject(); writer.WriteBoolean("listChanged", false); writer.WriteEndObject(); + writer.WriteEndObject(); + writer.WritePropertyName("serverInfo"); writer.WriteStartObject(); + writer.WriteString("name", "tablecloth-appcontainer-skill-poc"); writer.WriteString("version", "0.1.0"); writer.WriteEndObject(); + writer.WriteString("instructions", "Evaluation only. Fixed registered scripts run through the native AppContainer runner. Network-required registrations are blocked. MCP itself does not provide isolation."); + writer.WriteEndObject(); + }, token); + initialized = true; + } + else if (method == "ping") + await SendResultAsync(output, id.Value, writer => { writer.WriteStartObject(); writer.WriteEndObject(); }, token); + else + { + if (!ready) throw new RpcFailure(-32002, "Initialization is not complete."); + if (method == "tools/list") + { + if (parameters.ValueKind == JsonValueKind.Object && parameters.TryGetProperty("cursor", out _)) + throw new RpcFailure(-32602, "Pagination cursors are not supported."); + await SendResultAsync(output, id.Value, writer => WriteTools(writer, registry.Skills), token); + } + else if (method == "tools/call") + { + if (parameters.ValueKind != JsonValueKind.Object) throw new RpcFailure(-32602, "Invalid tool parameters."); + BrokerFiles.ObjectFields(parameters, ["name", "arguments", "_meta"]); + var name = BrokerFiles.Text(parameters, "name", 64); + if (!registry.Skills.Any(x => x.SkillId == name)) throw new RpcFailure(-32602, "Unknown registered tool."); + using var empty = JsonDocument.Parse("{}"); + var arguments = parameters.TryGetProperty("arguments", out var supplied) ? supplied : empty.RootElement; + if (arguments.ValueKind != JsonValueKind.Object) throw new RpcFailure(-32602, "Tool arguments must be an object."); + var execution = await registry.ExecuteAsync(name, arguments, token); + await SendResultAsync(output, id.Value, writer => WriteToolResult(writer, execution), token); + } + else throw new RpcFailure(-32601, "Method not found."); + } + } + catch (RpcFailure error) { await SendErrorAsync(output, id, error.Code, error.Message, token); } + catch (BrokerValidationException) { await SendErrorAsync(output, id, -32602, "Invalid method parameters.", token); } + catch (OperationCanceledException) { throw; } + catch { await SendErrorAsync(output, id, -32603, "Internal broker failure.", token); } + } + } + await Console.Error.WriteLineAsync("broker: session request limit reached."); + return 2; + } + + private static void WriteTools(Utf8JsonWriter writer, IReadOnlyList skills) + { + writer.WriteStartObject(); writer.WritePropertyName("tools"); writer.WriteStartArray(); + foreach (var skill in skills) + { + writer.WriteStartObject(); + writer.WriteString("name", skill.SkillId); + var description = skill.Kind == "python-json-stdin" ? "Run the registered deterministic calculator with one JSON operation. Six examples: percent.part {total,rate_percent}; discount.single {price,rate_percent}; loan {principal,annual_rate_percent,periods,periods_per_year,method}; date.add {date,years?,months?,days?}; time.convert {datetime,from_offset,to_offset}; bmi {weight_kg,height_cm}. Dates use YYYY-MM-DD; datetimes use YYYY-MM-DDTHH:MM; offsets use +/-HH:MM. All rates are user-supplied percentages, with taxes and fees excluded. BMI returns a number without medical classification." + : skill.Kind == "python-cli-fixed" ? "Run a reviewed script with manifest-fixed arguments; input must be an empty object." + : "Run the registered Node.js JSON-stdin evaluation fixture."; + if (skill.RequiresNetwork) description += " BLOCKED: this registration requires network access, which this broker never grants."; + writer.WriteString("description", description); + writer.WritePropertyName("inputSchema"); writer.WriteStartObject(); writer.WriteString("type", "object"); + writer.WritePropertyName("properties"); writer.WriteStartObject(); + if (skill.AllowedOperations.Count != 0) + { + writer.WritePropertyName("operation"); writer.WriteStartObject(); writer.WriteString("type", "string"); + writer.WritePropertyName("enum"); writer.WriteStartArray(); foreach (var operation in skill.AllowedOperations) writer.WriteStringValue(operation); writer.WriteEndArray(); writer.WriteEndObject(); + } + if (skill.Kind == "python-json-stdin") WriteCalculatorProperties(writer); + if (skill.Kind == "node-json-stdin") + foreach (var field in new[] { "a", "b" }) + { writer.WritePropertyName(field); writer.WriteStartObject(); writer.WriteString("type", "number"); writer.WriteEndObject(); } + writer.WriteEndObject(); + if (skill.AllowedOperations.Count != 0) + { writer.WritePropertyName("required"); writer.WriteStartArray(); writer.WriteStringValue("operation"); writer.WriteEndArray(); } + if (skill.Kind == "node-json-stdin") + { writer.WritePropertyName("required"); writer.WriteStartArray(); writer.WriteStringValue("a"); writer.WriteStringValue("b"); writer.WriteEndArray(); } + writer.WriteBoolean("additionalProperties", skill.Kind == "python-json-stdin"); + if (skill.Kind == "python-json-stdin") WriteCalculatorRequirements(writer, skill.AllowedOperations); + writer.WriteEndObject(); + writer.WriteEndObject(); + } + writer.WriteEndArray(); writer.WriteEndObject(); + } + + private static void WriteCalculatorProperties(Utf8JsonWriter writer) + { + foreach (var (name, description) in new[] + { + ("total", "Required by percent.part; the base value."), + ("rate_percent", "Required by percent.part and discount.single; 20 means 20 percent. Discount rates range from 0 to 100."), + ("price", "Required by discount.single; the original nonnegative price."), + ("principal", "Required by interest.simple and loan; initial amount, positive for a loan."), + ("annual_rate_percent", "Required by interest.simple and loan; user-supplied nominal annual percentage, from 0 to 1000."), + ("years", "Required by interest.simple; nonnegative duration. Optional integer for date.add."), + ("periods", "Required by loan; integer payment count from 1 to 1200."), + ("periods_per_year", "Required by loan; integer payments per year from 1 to 366, for example 12 for monthly payments."), + ("months", "Optional signed integer month shift for date.add; the original day anchors month-end clamping."), + ("days", "Optional signed integer day shift for date.add, applied after years and months."), + ("places", "Optional currency decimal places, an integer from 0 to 6; default 2 with half-up rounding."), + ("weight_kg", "Required by bmi; positive weight in kilograms."), + ("height_cm", "Required by bmi; positive height in centimetres. Result is a BMI number only, not a diagnosis.") + }) + { + writer.WritePropertyName(name); writer.WriteStartObject(); + writer.WritePropertyName("type"); writer.WriteStartArray(); writer.WriteStringValue("number"); writer.WriteStringValue("string"); writer.WriteEndArray(); + writer.WriteString("description", description); writer.WriteEndObject(); + } + foreach (var (name, description) in new[] + { + ("method", "Required by loan: annuity, equal_principal, or bullet."), + ("schedule", "Optional for loan: summary (default) or full. Large full schedules can exceed this broker's output limit."), + ("date", "Required by date.add; Gregorian YYYY-MM-DD."), + ("datetime", "Required by time.convert; timezone-free YYYY-MM-DDTHH:MM with optional seconds and microseconds."), + ("from_offset", "Required by time.convert; fixed UTC offset such as +09:00 or -05:30. Named zones and DST are unsupported."), + ("to_offset", "Required by time.convert; fixed UTC offset such as +00:00 or +05:45.") + }) + { + writer.WritePropertyName(name); writer.WriteStartObject(); writer.WriteString("type", "string"); + writer.WriteString("description", description); writer.WriteEndObject(); + } + } + + private static void WriteCalculatorRequirements(Utf8JsonWriter writer, IReadOnlyList allowed) + { + writer.WritePropertyName("allOf"); writer.WriteStartArray(); + foreach (var (operation, required) in new[] + { + ("percent.part", new[] { "total", "rate_percent" }), + ("discount.single", new[] { "price", "rate_percent" }), + ("loan", new[] { "principal", "annual_rate_percent", "periods", "periods_per_year", "method" }), + ("date.add", new[] { "date" }), + ("time.convert", new[] { "datetime", "from_offset", "to_offset" }), + ("bmi", new[] { "weight_kg", "height_cm" }) + }) + { + if (!allowed.Contains(operation, StringComparer.Ordinal)) continue; + writer.WriteStartObject(); writer.WritePropertyName("if"); writer.WriteStartObject(); + writer.WritePropertyName("properties"); writer.WriteStartObject(); writer.WritePropertyName("operation"); + writer.WriteStartObject(); writer.WriteString("const", operation); writer.WriteEndObject(); writer.WriteEndObject(); writer.WriteEndObject(); + writer.WritePropertyName("then"); writer.WriteStartObject(); writer.WritePropertyName("required"); writer.WriteStartArray(); + foreach (var field in required) writer.WriteStringValue(field); + writer.WriteEndArray(); writer.WriteEndObject(); writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + + private static void WriteToolResult(Utf8JsonWriter writer, SkillExecutionResult result) + { + // One bounded text block works with older MCP clients and preserves the execution evidence. + var text = Serialize(writer => WriteExecution(writer, result)); + writer.WriteStartObject(); writer.WriteBoolean("isError", result.IsError); + writer.WritePropertyName("content"); writer.WriteStartArray(); writer.WriteStartObject(); + writer.WriteString("type", "text"); writer.WriteString("text", Encoding.UTF8.GetString(text)); + writer.WriteEndObject(); writer.WriteEndArray(); writer.WriteEndObject(); + } + + public static void WriteExecution(Utf8JsonWriter writer, SkillExecutionResult result) + { + writer.WriteStartObject(); writer.WriteString("status", result.Status); + if (result.ExitCode is { } exitCode) writer.WriteNumber("exitCode", exitCode); else writer.WriteNull("exitCode"); + writer.WriteString("stdout", result.Stdout); writer.WriteString("stderr", result.Stderr); + writer.WriteBoolean("wasAppContainer", result.WasAppContainer); writer.WriteBoolean("timedOut", result.TimedOut); + writer.WriteBoolean("outputLimited", result.OutputLimited); + if (result.Failure is null) writer.WriteNull("failure"); else writer.WriteString("failure", result.Failure); + writer.WriteEndObject(); + } + + private static Task SendResultAsync(Stream output, JsonElement id, Action result, CancellationToken token) + => SendAsync(output, writer => + { + writer.WriteStartObject(); writer.WriteString("jsonrpc", "2.0"); writer.WritePropertyName("id"); id.WriteTo(writer); + writer.WritePropertyName("result"); result(writer); writer.WriteEndObject(); + }, token); + + private static Task SendErrorAsync(Stream output, JsonElement? id, int code, string message, CancellationToken token) + => SendAsync(output, writer => + { + writer.WriteStartObject(); writer.WriteString("jsonrpc", "2.0"); writer.WritePropertyName("id"); + if (id is { } value) value.WriteTo(writer); else writer.WriteNullValue(); + writer.WritePropertyName("error"); writer.WriteStartObject(); writer.WriteNumber("code", code); writer.WriteString("message", message); + writer.WriteEndObject(); writer.WriteEndObject(); + }, token); + + private static async Task SendAsync(Stream output, Action write, CancellationToken token) + { + var bytes = Serialize(write); + if (bytes.Length > MaximumResponseBytes) throw new BrokerValidationException("Response limit exceeded."); + await output.WriteAsync(bytes, token); + await output.WriteAsync(new byte[] { (byte)'\n' }, token); + await output.FlushAsync(token); + } + + private static byte[] Serialize(Action write) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) write(writer); + return stream.ToArray(); + } + + private sealed class RpcFailure(int code, string message) : Exception(message) { internal int Code => code; } + + private sealed class BoundedLines(Stream stream, int maximum) + { + private readonly byte[] _buffer = new byte[8192]; + private readonly UTF8Encoding _encoding = new(false, true); + private int _position; + private int _available; + internal async Task<(string? Text, bool Invalid, bool End)> ReadAsync(CancellationToken token) + { + using var line = new MemoryStream(); + while (true) + { + if (_position == _available) + { + _available = await stream.ReadAsync(_buffer, token); + _position = 0; + if (_available == 0) + { + if (line.Length == 0) return (null, false, true); + break; + } + } + var value = _buffer[_position++]; + if (value == (byte)'\n') break; + if (line.Length >= maximum) return (null, true, false); + line.WriteByte(value); + } + try { return (_encoding.GetString(line.GetBuffer(), 0, (int)line.Length).TrimEnd('\r'), false, false); } + catch (DecoderFallbackException) { return (null, true, false); } + } + } +} diff --git a/tools/appcontainer-skill-poc/CodexProbe.cs b/tools/appcontainer-skill-poc/CodexProbe.cs new file mode 100644 index 00000000..a5230a4d --- /dev/null +++ b/tools/appcontainer-skill-poc/CodexProbe.cs @@ -0,0 +1,428 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace TableCloth.AppContainerPoc; + +/// Real pinned CLI/MCP routing with a deterministic local Responses stub, never model inference. +public static class CodexProbe +{ + private const string Tool = "mcp__tablecloth_skill_poc__tablecloth-calculators"; + private const string ToolNamespace = "mcp__tablecloth_skill_poc"; + private const string ToolFunction = "tablecloth_calculators"; + private const string Marker = "LOCAL_DETERMINISTIC_TOOL_ROUTING_OK"; + private const string Call = "call_tablecloth_local_calculator"; + private const string Input = """{"operation":"discount.single","price":"100","rate_percent":"20"}"""; + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public static async Task RunAsync(string root, CancellationToken token) + { + var workspace = FindWorkspace(); + root = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar); + var expected = Preparation.Under(workspace, "Artifacts/appcontainer-skill-poc"); + if (!root.Equals(expected, StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException("The probe requires its project-owned artifact root."); + Preparation.RejectReparse(root); + var manifest = Preparation.Under(root, "evaluation-manifest.json"); + if (!File.Exists(manifest)) throw new FileNotFoundException("Prepare the owned evaluation manifest first."); + var executable = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "TableCloth", "ManagedAi", "runtimes", "openai-codex", "releases", + "0.156.1-x86_64-pc-windows-msvc", "bin", "codex.exe"); + if (!File.Exists(executable)) throw new FileNotFoundException("Pinned Codex 0.156.1 is unavailable."); + Preparation.RejectReparse(executable); + var dotnet = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "dotnet", "dotnet.exe"); + if (!File.Exists(dotnet)) throw new FileNotFoundException("The absolute .NET host is unavailable."); + var broker = typeof(CodexProbe).Assembly.Location; + Preparation.RejectReparse(broker); + var parent = Preparation.Under(root, "codex-home"); + Directory.CreateDirectory(parent); + var home = Preparation.Under(parent, "probe-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(home); + foreach (var folder in new[] { "user", "appdata", "localappdata", "temp", "dotnet-home" }) + Directory.CreateDirectory(Preparation.Under(home, folder)); + var config = Preparation.Under(home, "config.toml"); + var auth = Preparation.Under(home, "auth.json"); + var manifestHash = Preparation.Hash(manifest); + var script = Preparation.Under(root, "skills/tablecloth-calculators/scripts/calculate.py"); + var scriptHash = Preparation.Hash(script); + var port = FreeLoopbackPort(); + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + var toml = $$""" + model = "poc-local-deterministic" + model_provider = "local_poc" + approval_policy = "never" + sandbox_mode = "read-only" + cli_auth_credentials_store = "file" + check_for_update_on_startup = false + web_search = "disabled" + feedback.enabled = false + project_doc_max_bytes = 0 + [history] + persistence = "none" + [otel] + exporter = "none" + metrics_exporter = "none" + trace_exporter = "none" + log_user_prompt = false + [features] + shell_tool = false + unified_exec = false + shell_snapshot = false + multi_agent = false + hooks = false + remote_plugin = false + skill_mcp_dependency_install = false + [model_providers.local_poc] + name = "Local deterministic routing stub; no model inference" + base_url = "http://127.0.0.1:{{port}}/v1" + wire_api = "responses" + requires_openai_auth = false + request_max_retries = 0 + stream_max_retries = 0 + [mcp_servers.tablecloth_skill_poc] + command = {{Toml(dotnet)}} + args = [{{Toml(broker)}}, "mcp", {{Toml(manifest)}}] + cwd = {{Toml(root)}} + enabled_tools = ["tablecloth-calculators"] + required = true + startup_timeout_sec = 30 + tool_timeout_sec = 60 + default_tools_approval_mode = "prompt" + [mcp_servers.tablecloth_skill_poc.tools."tablecloth-calculators"] + approval_mode = "approve" + [mcp_servers.tablecloth_skill_poc.env] + DOTNET_GENERATE_ASPNET_CERTIFICATE = "false" + DOTNET_CLI_TELEMETRY_OPTOUT = "1" + DOTNET_CLI_HOME = {{Toml(Preparation.Under(home, "dotnet-home"))}} + """; + await File.WriteAllTextAsync(config, toml, new UTF8Encoding(false), token); + var configHash = Preparation.Hash(config); + var stub = new Stub(); + ProcessResult? version = null; + ProcessResult? execution = null; + string? failure = null; + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token); + deadline.CancelAfter(TimeSpan.FromSeconds(100)); + Task? serving = null; + try + { + listener.Start(); + serving = stub.ServeAsync(listener, deadline.Token); + version = await RunProcessAsync(executable, ["--version"], "", root, home, deadline.Token); + if (version.ExitCode != 0 || version.Stdout.Trim() != "codex-cli 0.156.1") + throw new InvalidDataException("The executable did not report pinned Codex 0.156.1."); + execution = await RunProcessAsync(executable, + ["-c", "features.shell_tool=false", "-c", "features.unified_exec=false", "--ask-for-approval", "never", + "exec", "--json", "--sandbox", "read-only", "--skip-git-repo-check", "--ephemeral", "--color", "never", "-C", root, "-"], + "Use the registered calculator MCP tool for the synthetic example: price 100, discount 20 percent. Return the verified tool result. This is a local routing test, not a real model request.", root, home, deadline.Token); + } + catch (OperationCanceledException) { failure = token.IsCancellationRequested ? "canceled" : "timeout"; } + catch (Exception error) { failure = error.GetType().Name; } + finally + { + deadline.Cancel(); + listener.Close(); + if (serving is not null) + try { await serving; } + catch (Exception error) when (error is OperationCanceledException or HttpListenerException or ObjectDisposedException) { } + } + var mcpEvent = execution is not null && HasCompletedMcpEvent(execution.Stdout); + var finalEvent = execution is not null && HasFinalEvent(execution.Stdout); + var unchanged = Preparation.Hash(config) == configHash && Preparation.Hash(manifest) == manifestHash && Preparation.Hash(script) == scriptHash; + var noAuthFile = !File.Exists(auth); + var passed = failure is null && stub.Failure is null && execution?.ExitCode == 0 && !execution.OutputLimited && stub.ResponseRequests == 2 && + stub.ToolWasAdvertised && !stub.ShellWasAdvertised && !stub.CredentialHeaderPresent && + stub.Execution is not null && mcpEvent && finalEvent && unchanged && noAuthFile; + var evidence = new + { + recordedUtc = DateTimeOffset.UtcNow, success = passed, + qualification = "Actual pinned Codex CLI -> MCP -> native AppContainer -> pinned calculator, driven by deterministic loopback Responses SSE. This does not test real model selection or inference.", + modelInference = false, authenticationAttempted = false, existingAuthenticationCopied = false, + configuredProvider = "loopback-only deterministic stub", shellTool = false, unifiedExec = false, + approvalPolicy = "never", approvedMcpTool = "tablecloth_skill_poc/tablecloth-calculators", + codexVersion = version?.Stdout.Trim(), configSha256 = configHash, manifestSha256 = manifestHash, calculatorSha256 = scriptHash, + filesUnchanged = unchanged, isolatedAuthFileAbsent = noAuthFile, + responseRequests = stub.ResponseRequests, advertisedTools = stub.AdvertisedTools, + toolCallNamespace = stub.CallNamespace, toolCallName = stub.CallName, + toolWasAdvertised = stub.ToolWasAdvertised, shellWasAdvertised = stub.ShellWasAdvertised, + credentialHeaderPresent = stub.CredentialHeaderPresent, actualAppContainerResult = stub.Execution, + completedMcpEvent = mcpEvent, finalAgentEvent = finalEvent, exitCode = execution?.ExitCode, + observedMcpEventTool = execution is null ? null : McpEventToolName(execution.Stdout), + failure, stubFailure = stub.Failure, processOutputLimited = execution?.OutputLimited ?? false, + codexHomeRelative = Path.GetRelativePath(root, home) + }; + await File.WriteAllTextAsync(Preparation.Under(root, "codex-probe-evidence.json"), JsonSerializer.Serialize(evidence, JsonOptions), CancellationToken.None); + if (execution is not null) + { + await File.WriteAllTextAsync(Preparation.Under(home, "events.jsonl"), execution.Stdout, CancellationToken.None); + await File.WriteAllTextAsync(Preparation.Under(home, "stderr.txt"), execution.Stderr, CancellationToken.None); + } + Console.WriteLine((passed ? "PASS" : "FAIL") + " pinned Codex deterministic local MCP routing; real model inference: 0"); + return passed ? 0 : 1; + } + + private sealed record ProcessResult(int ExitCode, string Stdout, string Stderr, bool OutputLimited); + + private static async Task RunProcessAsync(string executable, string[] arguments, string input, + string root, string home, CancellationToken token) + { + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, CreateNoWindow = true, WorkingDirectory = root, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + StandardInputEncoding = new UTF8Encoding(false), StandardOutputEncoding = new UTF8Encoding(false), StandardErrorEncoding = new UTF8Encoding(false) + }; + foreach (var argument in arguments) start.ArgumentList.Add(argument); + start.Environment.Clear(); + var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + foreach (var (key, value) in new Dictionary + { + ["SystemRoot"] = windows, ["WINDIR"] = windows, ["ComSpec"] = Path.Combine(windows, "System32", "cmd.exe"), + ["CODEX_HOME"] = home, ["USERPROFILE"] = Preparation.Under(home, "user"), ["HOME"] = Preparation.Under(home, "user"), + ["APPDATA"] = Preparation.Under(home, "appdata"), ["LOCALAPPDATA"] = Preparation.Under(home, "localappdata"), + ["TEMP"] = Preparation.Under(home, "temp"), ["TMP"] = Preparation.Under(home, "temp"), + ["PATH"] = Path.GetDirectoryName(executable)! + ";" + Path.Combine(windows, "System32"), ["PATHEXT"] = ".EXE", + ["DOTNET_GENERATE_ASPNET_CERTIFICATE"] = "false", ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1", + ["DOTNET_CLI_HOME"] = Preparation.Under(home, "dotnet-home"), ["NO_PROXY"] = "127.0.0.1,localhost" + }) start.Environment[key] = value; + using var process = new Process { StartInfo = start }; + process.Start(); + void Kill() { try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { } } + using var stop = token.Register(Kill); + var limited = 0; + var count = 0; + async Task ReadAsync(StreamReader reader) + { + var saved = new StringBuilder(); + var buffer = new char[4096]; + while (true) + { + var length = await reader.ReadAsync(buffer); + if (length == 0) return saved.ToString(); + var total = Interlocked.Add(ref count, length); + var keep = Math.Clamp(1048576 - (total - length), 0, length); + if (keep != 0) saved.Append(buffer, 0, keep); + if (total <= 1048576) continue; + Interlocked.Exchange(ref limited, 1); Kill(); + } + } + var stdout = ReadAsync(process.StandardOutput); + var stderr = ReadAsync(process.StandardError); + try + { + await process.StandardInput.WriteAsync(input.AsMemory(), token); + process.StandardInput.Close(); + await process.WaitForExitAsync(token); + await Task.WhenAll(stdout, stderr); + return new(process.ExitCode, await stdout, await stderr, Volatile.Read(ref limited) != 0); + } + finally + { + Kill(); + try { await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); } catch (TimeoutException) { } + } + } + + private sealed class Stub + { + internal int ResponseRequests; + internal bool ToolWasAdvertised; + internal bool ShellWasAdvertised; + internal bool CredentialHeaderPresent; + internal string[] AdvertisedTools = []; + internal string? CallNamespace; + internal string? CallName; + internal JsonElement? Execution; + internal string? Failure; + + internal async Task ServeAsync(HttpListener listener, CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + var context = await listener.GetContextAsync().WaitAsync(token); + try { await ReplyAsync(context, token); } + finally { context.Response.Close(); } + } + } + catch (Exception error) when (token.IsCancellationRequested && error is OperationCanceledException or HttpListenerException or ObjectDisposedException) { } + catch (Exception error) { Failure = error.GetType().Name; } + } + + private async Task ReplyAsync(HttpListenerContext context, CancellationToken token) + { + if (context.Request.RemoteEndPoint is not { } remote || !IPAddress.IsLoopback(remote.Address) || + context.Request.HttpMethod != "POST" || context.Request.Url?.AbsolutePath != "/v1/responses") + { context.Response.StatusCode = 404; return; } + CredentialHeaderPresent |= !string.IsNullOrEmpty(context.Request.Headers["Authorization"]); + using var body = new MemoryStream(); + var buffer = new byte[8192]; + while (true) + { + var length = await context.Request.InputStream.ReadAsync(buffer, token); + if (length == 0) break; + if (body.Length + length > 2097152) { context.Response.StatusCode = 413; return; } + body.Write(buffer, 0, length); + } + using var request = JsonDocument.Parse(body.ToArray()); + ResponseRequests++; + if (ResponseRequests > 2) { context.Response.StatusCode = 429; return; } + var routes = request.RootElement.TryGetProperty("tools", out var tools) ? ReadToolRoutes(tools).ToArray() : []; + AdvertisedTools = routes.Select(x => x.Namespace is null ? x.Name : x.Namespace + "." + x.Name).Distinct().ToArray(); + var selected = routes.FirstOrDefault(x => x.Namespace == ToolNamespace && x.Name == ToolFunction) + ?? routes.FirstOrDefault(x => x.Namespace is null && x.Name == Tool); + ToolWasAdvertised |= selected is not null; + ShellWasAdvertised |= routes.Any(x => x.Name is "shell" or "local_shell" or "exec_command" or "write_stdin"); + context.Response.ContentType = "text/event-stream"; + context.Response.SendChunked = true; + context.Response.Headers["Cache-Control"] = "no-cache"; + object item; + if (ResponseRequests == 1) + { + if (selected is null) + { + Failure = "expected-calculator-tool-not-advertised"; + context.Response.StatusCode = 422; + return; + } + CallNamespace = selected.Namespace; + CallName = selected.Name; + var call = new Dictionary + { + ["id"] = "fc_tablecloth_local", ["type"] = "function_call", ["status"] = "completed", + ["call_id"] = Call, ["name"] = selected.Name, ["arguments"] = Input + }; + if (selected.Namespace is not null) call["namespace"] = selected.Namespace; + item = call; + } + else + { + if (request.RootElement.TryGetProperty("input", out var inputs)) Execution = FindExecution(inputs); + item = new { id = "msg_tablecloth_local", type = "message", status = "completed", role = "assistant", + content = new[] { new { type = "output_text", text = Execution is not null ? Marker : "LOCAL_DETERMINISTIC_TOOL_ROUTING_FAILED", annotations = Array.Empty() } } }; + } + var response = new + { + id = "resp_tablecloth_local_" + ResponseRequests, @object = "response", created_at = 1792897200, + status = "completed", model = "poc-local-deterministic", output = new[] { item }, + usage = new { input_tokens = 1, output_tokens = 1, total_tokens = 2, output_tokens_details = new { reasoning_tokens = 0 } } + }; + await EventAsync(context.Response, "response.created", new { type = "response.created", response = new { response.id, @object = "response", status = "in_progress", output = Array.Empty() } }, token); + await EventAsync(context.Response, "response.output_item.done", new { type = "response.output_item.done", output_index = 0, item }, token); + await EventAsync(context.Response, "response.completed", new { type = "response.completed", response }, token); + } + } + + private sealed record ToolRoute(string? Namespace, string Name); + + // Codex 0.156.1 protocol/models.rs separates FunctionCall.namespace and FunctionCall.name. + // Namespace declarations group their leaf function specs in a nested tools array. + private static IEnumerable ReadToolRoutes(JsonElement tools, string? parent = null) + { + if (tools.ValueKind != JsonValueKind.Array) yield break; + foreach (var tool in tools.EnumerateArray()) + { + if (tool.ValueKind != JsonValueKind.Object) continue; + var type = tool.TryGetProperty("type", out var kind) && kind.ValueKind == JsonValueKind.String ? kind.GetString() : null; + var name = tool.TryGetProperty("name", out var named) && named.ValueKind == JsonValueKind.String ? named.GetString() : null; + if (type == "namespace" && parent is null && !string.IsNullOrEmpty(name) && tool.TryGetProperty("tools", out var nested)) + { + foreach (var route in ReadToolRoutes(nested, name)) yield return route; + } + else + { + if (name is null && tool.TryGetProperty("function", out var function) && function.ValueKind == JsonValueKind.Object && + function.TryGetProperty("name", out named) && named.ValueKind == JsonValueKind.String) name = named.GetString(); + if (!string.IsNullOrEmpty(name ?? type)) yield return new(parent, name ?? type!); + } + } + } + + private static async Task EventAsync(HttpListenerResponse response, string name, object value, CancellationToken token) + { + var bytes = Encoding.UTF8.GetBytes("event: " + name + "\ndata: " + JsonSerializer.Serialize(value) + "\n\n"); + await response.OutputStream.WriteAsync(bytes, token); + await response.OutputStream.FlushAsync(token); + } + + private static JsonElement? FindExecution(JsonElement value, int depth = 0) + { + if (depth > 20) return null; + if (value.ValueKind == JsonValueKind.Object) + { + if (value.TryGetProperty("wasAppContainer", out var container) && container.ValueKind == JsonValueKind.True && + value.TryGetProperty("status", out var status) && status.GetString() == "completed" && + value.TryGetProperty("exitCode", out var exit) && exit.TryGetInt32(out var code) && code == 0 && + value.TryGetProperty("stdout", out var output) && output.ValueKind == JsonValueKind.String) + { + try + { + using var result = JsonDocument.Parse(output.GetString()!); + if (result.RootElement.GetProperty("ok").GetBoolean() && result.RootElement.GetProperty("result").GetProperty("final").GetString() == "80.00") + return value.Clone(); + } + catch (Exception error) when (error is JsonException or KeyNotFoundException or InvalidOperationException) { } + } + foreach (var property in value.EnumerateObject()) + if (FindExecution(property.Value, depth + 1) is { } found) return found; + } + else if (value.ValueKind == JsonValueKind.Array) + { + foreach (var child in value.EnumerateArray()) + if (FindExecution(child, depth + 1) is { } found) return found; + } + else if (value.ValueKind == JsonValueKind.String && value.GetString() is { Length: < 1048576 } text && + text.TrimStart().StartsWith('{')) + { + try { using var parsed = JsonDocument.Parse(text); return FindExecution(parsed.RootElement, depth + 1); } + catch (JsonException) { } + } + return null; + } + + private static bool HasCompletedMcpEvent(string lines) => Events(lines).Any(value => + value.TryGetProperty("type", out var type) && type.GetString() == "item.completed" && + value.TryGetProperty("item", out var item) && item.TryGetProperty("type", out var kind) && kind.GetString() == "mcp_tool_call" && + item.TryGetProperty("server", out var server) && server.GetString() == "tablecloth_skill_poc" && + item.TryGetProperty("tool", out var tool) && tool.GetString() is "tablecloth-calculators" or ToolFunction && FindExecution(item) is not null); + + private static string? McpEventToolName(string lines) => Events(lines) + .Where(value => value.TryGetProperty("type", out var type) && type.GetString() == "item.completed" && + value.TryGetProperty("item", out var item) && item.TryGetProperty("type", out var kind) && kind.GetString() == "mcp_tool_call" && + item.TryGetProperty("server", out var server) && server.GetString() == "tablecloth_skill_poc") + .Select(value => value.GetProperty("item").TryGetProperty("tool", out var tool) ? tool.GetString() : null).FirstOrDefault(); + + private static bool HasFinalEvent(string lines) => Events(lines).Any(value => + value.TryGetProperty("type", out var type) && type.GetString() == "item.completed" && + value.TryGetProperty("item", out var item) && item.TryGetProperty("type", out var kind) && kind.GetString() == "agent_message" && + item.TryGetProperty("text", out var text) && text.GetString() == Marker); + + private static IEnumerable Events(string lines) + { + foreach (var line in lines.Split('\n', StringSplitOptions.RemoveEmptyEntries)) + { + JsonElement? value = null; + try { using var parsed = JsonDocument.Parse(line); value = parsed.RootElement.Clone(); } catch (JsonException) { } + if (value is { } item) yield return item; + } + } + + private static string Toml(string value) => JsonSerializer.Serialize(value); + + private static int FreeLoopbackPort() + { + using var tcp = new TcpListener(IPAddress.Loopback, 0); + tcp.Start(); return ((IPEndPoint)tcp.LocalEndpoint).Port; + } + + private static string FindWorkspace() + { + for (var directory = new DirectoryInfo(AppContext.BaseDirectory); directory is not null; directory = directory.Parent) + if (File.Exists(Path.Combine(directory.FullName, "tools", "appcontainer-skill-poc", "Poc.csproj"))) return directory.FullName; + throw new InvalidDataException("The project workspace could not be located."); + } +} diff --git a/tools/appcontainer-skill-poc/Evaluation.cs b/tools/appcontainer-skill-poc/Evaluation.cs new file mode 100644 index 00000000..01820e73 --- /dev/null +++ b/tools/appcontainer-skill-poc/Evaluation.cs @@ -0,0 +1,294 @@ +using System.Diagnostics; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; + +namespace TableCloth.AppContainerPoc; + +internal static class Evaluation +{ + private sealed record Check(string Name, bool Passed, object? Evidence); + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + internal static async Task RunAsync(string root, bool includePublicApi, CancellationToken token) + { + root = Path.GetFullPath(root); + Preparation.RejectReparse(root); + var manifest = Preparation.Under(root, "evaluation-manifest.json"); + var registry = await SkillExecutionRegistry.LoadAsync(manifest, token); + var runner = new AppContainerProcessRunner(root); + var checks = new List(); + async Task CheckAsync(string name, Func> run) + { + try + { + var (passed, evidence) = await run(); + checks.Add(new(name, passed, evidence)); + Console.WriteLine((passed ? "PASS " : "FAIL ") + name); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) { throw; } + catch (Exception exception) + { + checks.Add(new(name, false, exception.GetType().Name + ": " + exception.Message)); + Console.WriteLine("FAIL " + name + ": " + exception.GetType().Name); + } + } + async Task<(bool, object?)> Calculate(string json, Func expected) + { + var result = await registry.ExecuteAsync("tablecloth-calculators", Parse(json), token); + var passed = result.Status == "completed" && result.WasAppContainer && expected(Parse(result.Stdout).GetProperty("result")); + return (passed, result); + } + + foreach (var check in await BrokerSelfChecks.RunAsync(root, token)) + { + checks.Add(new("broker-offline." + check.Name, check.Passed, check.Failure)); + Console.WriteLine((check.Passed ? "PASS " : "FAIL ") + "broker-offline." + check.Name); + } + + var aclPaths = new[] { root, Path.Combine(root, "runtime/python"), Path.Combine(root, "runtime/python/python.exe"), + Path.Combine(root, "runtime/node/node.exe"), Path.Combine(root, "skills/tablecloth-calculators/scripts/calculate.py") }; + var originalAcls = aclPaths.ToDictionary(x => x, ReadDacl); + await CheckAsync("calculator.discount", () => Calculate("""{"operation":"discount.single","price":"100","rate_percent":"20"}""", r => r.GetProperty("final").GetString() == "80.00")); + await CheckAsync("calculator.percent", () => Calculate("""{"operation":"percent.part","total":"200","rate_percent":"12.5"}""", r => r.GetProperty("value").GetString() == "25")); + await CheckAsync("calculator.leap-date", () => Calculate("""{"operation":"date.add","date":"2024-02-29","years":1}""", r => r.GetProperty("date").GetString() == "2025-02-28")); + await CheckAsync("calculator.time-rollover", () => Calculate("""{"operation":"time.convert","datetime":"2024-01-01T01:00","from_offset":"+09:00","to_offset":"-05:00"}""", r => r.GetProperty("converted").GetString()!.StartsWith("2023-12-31T11:00", StringComparison.Ordinal))); + await CheckAsync("calculator.bmi", () => Calculate("""{"operation":"bmi","weight_kg":"70","height_cm":"175"}""", r => r.GetProperty("bmi").GetString() == "22.86")); + await CheckAsync("calculator.zero-rate-loan", () => Calculate("""{"operation":"loan","principal":"100","annual_rate_percent":"0","periods":3,"periods_per_year":12,"method":"annuity","schedule":"full"}""", r => r.GetProperty("schedule")[2].GetProperty("closing_balance").GetString() == "0.00")); + await CheckAsync("kosis.fixed-dry-run", async () => + { + var result = await registry.ExecuteAsync("kosis-stats", Parse("{}"), token); + return (result.Status == "completed" && result.WasAppContainer && Parse(result.Stdout).GetProperty("via_proxy").GetBoolean(), result); + }); + await CheckAsync("bok.broker-network-denied", async () => + { + var result = await registry.ExecuteAsync("bok-ecos-stats", Parse("{}"), token); + return (result.Status == "blocked" && result.Failure == "network-policy", result); + }); + await CheckAsync("node.registered-script", async () => + { + var result = await registry.ExecuteAsync("node-runtime-fixture", Parse("""{"a":12,"b":30}"""), token); + return (result.Status == "completed" && result.WasAppContainer && Parse(result.Stdout).GetProperty("sum").GetInt32() == 42, result); + }); + foreach (var skill in new[] { "tablecloth-calculators", "bok-ecos-stats" }) + { + await CheckAsync(skill + ".upstream-unit-tests-contained", async () => + { + var work = Preparation.Under(root, "probe-work/" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(work); + var python = Preparation.Under(root, "runtime/python/python.exe"); + var source = Preparation.Under(root, "skills/" + skill); + var result = await runner.RunAsync(new(python, ["-I", "-S", "-B", "-X", "utf8", "-m", "unittest", "discover", "-s", Path.Combine(source, "tests")], + work, "", [Path.GetDirectoryName(python)!, source], [work]), token); + return (result.ExitCode == 0 && result.WasAppContainer && result.Failure is null && result.Stderr.Contains("\nOK", StringComparison.Ordinal), result); + }); + } + await CheckAsync("broker.no-arbitrary-code", async () => + { + var result = await registry.ExecuteAsync("tablecloth-calculators", Parse("""{"operation":"eval","code":"print(1)"}"""), token); + var args = await registry.ExecuteAsync("kosis-stats", Parse("""{"arguments":["--direct"]}"""), token); + return (result.Status == "blocked" && args.Status == "blocked", new { result, args }); + }); + + var privateDirectory = Preparation.Under(root, "private-canary"); + Directory.CreateDirectory(privateDirectory); + var security = new DirectorySecurity(); + security.SetAccessRuleProtection(true, false); + security.AddAccessRule(new FileSystemAccessRule(WindowsIdentity.GetCurrent().User!, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + new DirectoryInfo(privateDirectory).SetAccessControl(security); + var canary = Path.Combine(privateDirectory, "canary.txt"); + await File.WriteAllTextAsync(canary, "owned-synthetic-canary", token); + await CheckAsync("probe.host-positive-control", async () => + { + var value = await File.ReadAllTextAsync(canary, token); + await File.WriteAllTextAsync(Path.Combine(privateDirectory, "host-write.txt"), "host", token); + return (value == "owned-synthetic-canary", "Host can read and write the synthetic private directory."); + }); + Environment.SetEnvironmentVariable("TABLECLOTH_POC_SYNTHETIC_SECRET", "synthetic-not-a-credential"); + try + { + foreach (var language in new[] { "python", "node" }) + { + await CheckAsync(language + ".isolation", async () => + { + var input = JsonSerializer.Serialize(new { outside_read = canary, outside_write = Path.Combine(privateDirectory, language + "-write.txt") }); + var result = await RunProbeAsync(root, runner, language, language == "python" ? "isolation.py" : "isolation.cjs", input, token); + var evidence = result.ExitCode == 0 ? Parse(result.Stdout) : default; + var passed = result.ExitCode == 0 && result.WasAppContainer && result.Failure is null && + AccessDenied(evidence.GetProperty("outside_read")) && + AccessDenied(evidence.GetProperty("outside_write")) && + evidence.GetProperty("work_write").GetBoolean() && evidence.GetProperty("inherited_secret").ValueKind == JsonValueKind.Null && + ProcessDenied(evidence.GetProperty("child")) && + NetworkDenied(evidence.GetProperty("network")); + return (passed, result); + }); + } + } + finally { Environment.SetEnvironmentVariable("TABLECLOTH_POC_SYNTHETIC_SECRET", null); } + await CheckAsync("python.utf8-roundtrip", async () => + { + var result = await RunProbeAsync(root, runner, "python", "unicode.py", "{\"text\":\"격리 확인\"}", token); + return (result.ExitCode == 0 && result.WasAppContainer && result.Failure is null && + Parse(result.Stdout).GetProperty("text").GetString() == "격리 확인" && + Parse(result.Stdout).GetProperty("stdout_encoding").GetString() == "utf-8", result); + }); + await CheckAsync("node.child-positive-control-two-process-job", async () => + { + var input = JsonSerializer.Serialize(new { outside_read = canary, outside_write = Path.Combine(privateDirectory, "node-positive-write.txt") }); + var result = await RunProbeAsync(root, runner, "node", "isolation.cjs", input, token, maximumProcesses: 2); + return (result.ExitCode == 0 && result.WasAppContainer && result.Failure is null && + Parse(result.Stdout).GetProperty("child").GetProperty("allowed").GetBoolean() && + Parse(result.Stdout).GetProperty("child").GetProperty("exit").GetInt32() == 0, result); + }); + await CheckAsync("python.timeout", async () => + { + var result = await RunProbeAsync(root, runner, "python", "wait.py", "", token, timeout: 700); + return (result.TimedOut && result.WasAppContainer && result.Failure is null, result); + }); + await CheckAsync("python.output-bound", async () => + { + var result = await RunProbeAsync(root, runner, "python", "output.py", "", token, maximumOutput: 8192); + return (result.OutputLimited && result.WasAppContainer && result.Failure is null && Encoding.UTF8.GetByteCount(result.Stdout + result.Stderr) <= 8192, + result with { Stdout = "" }); + }); + await CheckAsync("python.memory-bound", async () => + { + var result = await RunProbeAsync(root, runner, "python", "memory.py", "", token, memoryLimit: 64L * 1024 * 1024); + return (result.ExitCode == 0 && result.WasAppContainer && result.Failure is null && Parse(result.Stdout).GetProperty("memory_error").GetBoolean(), result); + }); + await CheckAsync("python.cancellation", async () => + { + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token); + cancellation.CancelAfter(700); + try { await RunProbeAsync(root, runner, "python", "wait.py", "", cancellation.Token); return (false, "Cancellation was not observed."); } + catch (OperationCanceledException) when (!token.IsCancellationRequested) { return (true, "Cancellation terminated the worker and awaited cleanup."); } + }); + await CheckAsync("mcp.stdio-real-worker", () => McpAsync(manifest, token)); + await CheckAsync("native.acl-restored", () => Task.FromResult<(bool, object?)>((originalAcls.All(x => ReadDacl(x.Key) == x.Value), "Compared original and final DACLs for owned root, Python runtime, Node executable, and calculator script."))); + await CheckAsync("native.workspace-cleaned", () => Task.FromResult<(bool, object?)>(( + !Directory.EnumerateDirectories(Preparation.Under(root, ".appcontainer-runs")).Any(), "No per-run native workspaces remain."))); + + if (includePublicApi) + { + foreach (var skill in new[] { "bok-ecos-stats", "kosis-stats" }) + { + await CheckAsync(skill + ".public-api-evaluation", async () => + { + var work = Preparation.Under(root, "public-api-work/" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(work); + var script = Preparation.Under(root, "skills/" + skill + "/scripts/" + (skill == "bok-ecos-stats" ? "bok_ecos.py" : "run_kosis_stats.py")); + var arguments = new List { "-I", "-S", "-B", "-X", "utf8", script }; + if (skill == "bok-ecos-stats") + { + var secrets = Path.Combine(work, "empty-secrets.env"); + await File.WriteAllTextAsync(secrets, "", token); + arguments.AddRange(["key", "--limit", "1", "--timeout", "15", "--secrets-path", secrets]); + } + else arguments.AddRange(["search", "--query", "인구", "--result-count", "1", "--json", "--timeout", "15"]); + var python = Preparation.Under(root, "runtime/python/python.exe"); + var result = await runner.RunAsync(new(python, arguments, work, "", [Path.GetDirectoryName(python)!, Path.GetDirectoryName(script)!], [work], + AllowNetwork: true, TimeoutMilliseconds: 22000), token); + var validData = result.ExitCode == 0 && !result.Stdout.Contains('\uFFFD') && (skill == "bok-ecos-stats" + ? Parse(result.Stdout).GetProperty("result").GetString() == "ok" && Parse(result.Stdout).GetProperty("rows").GetArrayLength() == 1 + : Parse(result.Stdout).ValueKind == JsonValueKind.Array && Parse(result.Stdout).GetArrayLength() == 1 && + Parse(result.Stdout)[0].GetProperty("QUERY").GetString() == "인구"); + return (result.WasAppContainer && validData && result.Failure is null, + new { capability = "internetClient, no domain allowlist; fixed host-owned evaluation only", sourceUnmodified = true, result }); + }); + } + } + var report = new { timestampUtc = DateTimeOffset.UtcNow, os = Environment.OSVersion.VersionString, + architecture = System.Runtime.InteropServices.RuntimeInformation.OSArchitecture.ToString(), + shellToolEnabled = false, publicApiEvaluation = includePublicApi, passed = checks.Count(x => x.Passed), total = checks.Count, checks }; + var reportPath = Preparation.Under(root, includePublicApi ? "evaluation-public-api.json" : "evaluation.json"); + await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, JsonOptions), token); + Console.WriteLine($"{report.passed}/{report.total} checks passed. Evidence: {reportPath}"); + return checks.All(x => x.Passed) ? 0 : 1; + } + + private static string ReadDacl(string path) + { + Win32.CheckError(Win32.GetNamedSecurityInfo(path, 1, Win32.SecurityInformation, out _, out _, out _, out _, out var descriptor)); + try + { + Win32.Check(Win32.DescriptorToString(descriptor, 1, Win32.SecurityInformation, out var text, out _)); + try + { + var security = new RawSecurityDescriptor(Marshal.PtrToStringUni(text)!); + var labels = new List(); + if (security.SystemAcl is not null) + foreach (GenericAce ace in security.SystemAcl) + if ((byte)ace.AceType == 17) + { + var binary = new byte[ace.BinaryLength]; ace.GetBinaryForm(binary, 0); + labels.Add(Convert.ToHexString(binary)); + } + // LABEL-only restoration can retain an empty SACL control bit. Compare actual label ACEs. + return security.GetSddlForm(AccessControlSections.Access) + "|ML:" + string.Join(',', labels); + } + finally { Win32.LocalFree(text); } + } + finally { Win32.LocalFree(descriptor); } + } + + private static JsonElement Parse(string value) { using var json = JsonDocument.Parse(value); return json.RootElement.Clone(); } + + private static bool AccessDenied(JsonElement value) => !value.GetProperty("allowed").GetBoolean() && + (value.TryGetProperty("winerror", out var winerror) && winerror.ValueKind == JsonValueKind.Number && winerror.GetInt32() is 5 or 1816 || + value.TryGetProperty("errno", out var errno) && errno.ValueKind == JsonValueKind.Number && errno.GetInt32() == 13 || + value.TryGetProperty("code", out var code) && code.GetString() is "EACCES" or "EPERM" or "EAGAIN"); + + private static bool NetworkDenied(JsonElement value) => !value.GetProperty("allowed").GetBoolean() && + (value.TryGetProperty("winerror", out var winerror) && winerror.ValueKind == JsonValueKind.Number && winerror.GetInt32() == 10013 || + value.TryGetProperty("code", out var code) && code.GetString() is "EACCES" or "EPERM"); + + private static bool ProcessDenied(JsonElement value) => AccessDenied(value) || + (!value.GetProperty("allowed").GetBoolean() && value.TryGetProperty("code", out var code) && code.GetString() == "UNKNOWN" && + value.TryGetProperty("errno", out var errno) && errno.GetInt32() == -4094); + + private static async Task RunProbeAsync(string root, AppContainerProcessRunner runner, + string language, string probe, string input, CancellationToken token, int timeout = 10000, int maximumOutput = 65536, long memoryLimit = 268435456, int maximumProcesses = 1) + { + var work = Preparation.Under(root, "probe-work/" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(work); + var executable = Preparation.Under(root, language == "python" ? "runtime/python/python.exe" : "runtime/node/node.exe"); + var script = Preparation.Under(root, "probes/" + probe); + var arguments = language == "python" ? new[] { "-I", "-S", "-B", "-X", "utf8", script } : new[] { "--preserve-symlinks-main", script }; + var original = ReadDacl(work); + var result = await runner.RunAsync(new(executable, arguments, work, input, + [Path.GetDirectoryName(executable)!, Path.GetDirectoryName(script)!], [work], + TimeoutMilliseconds: timeout, MaxOutputBytes: maximumOutput, MemoryLimitBytes: memoryLimit, MaxProcesses: maximumProcesses), token); + return original == ReadDacl(work) ? result : result with { Failure = "probe-work-dacl-or-label-not-restored" }; + } + + private static async Task<(bool, object?)> McpAsync(string manifest, CancellationToken token) + { + var host = Environment.ProcessPath!; + var start = new ProcessStartInfo(host) { UseShellExecute = false, RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true }; + if (Path.GetFileNameWithoutExtension(host).Equals("dotnet", StringComparison.OrdinalIgnoreCase)) start.ArgumentList.Add(typeof(Evaluation).Assembly.Location); + start.ArgumentList.Add("mcp"); start.ArgumentList.Add(manifest); + using var process = Process.Start(start) ?? throw new IOException("Could not start MCP broker."); + var output = process.StandardOutput.ReadToEndAsync(token); + var error = process.StandardError.ReadToEndAsync(token); + await process.StandardInput.WriteLineAsync("""{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"poc-evaluation","version":"1"}}}"""); + await process.StandardInput.WriteLineAsync("""{"jsonrpc":"2.0","method":"notifications/initialized"}"""); + await process.StandardInput.WriteLineAsync("""{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"""); + await process.StandardInput.WriteLineAsync("""{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"tablecloth-calculators","arguments":{"operation":"discount.single","price":"100","rate_percent":"20"}}}"""); + process.StandardInput.Close(); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(token); + deadline.CancelAfter(45000); + try { await process.WaitForExitAsync(deadline.Token); } + catch { process.Kill(entireProcessTree: true); await process.WaitForExitAsync(CancellationToken.None); throw; } + var lines = (await output).Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(Parse).ToArray(); + var execution = lines.Length == 3 && lines[2].TryGetProperty("result", out var result) + ? Parse(result.GetProperty("content")[0].GetProperty("text").GetString()!) : default; + return (process.ExitCode == 0 && lines.Length == 3 && lines[1].GetProperty("result").GetProperty("tools").GetArrayLength() == 4 && + execution.GetProperty("status").GetString() == "completed" && execution.GetProperty("wasAppContainer").GetBoolean(), + new { process.ExitCode, responses = lines, stderr = await error }); + } +} diff --git a/tools/appcontainer-skill-poc/Native/AclScope.cs b/tools/appcontainer-skill-poc/Native/AclScope.cs new file mode 100644 index 00000000..117b21d3 --- /dev/null +++ b/tools/appcontainer-skill-poc/Native/AclScope.cs @@ -0,0 +1,395 @@ +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Text.Json; + +namespace TableCloth.AppContainerPoc; + +internal sealed class AclScope(OwnedPaths paths, nint packageSid, string profileName, CancellationToken cancellationToken, + string? journalPath = null) +{ + private sealed record Snapshot(string Path, string Sddl, Win32.FileInformation Identity) + { + internal bool DaclChanged, LabelChanged, MayChangeLabel; + } + private readonly List _snapshots = []; + private readonly HashSet _captured = new(StringComparer.OrdinalIgnoreCase); + private readonly string _journal = journalPath ?? paths.Check(Path.Combine(paths.Root, ".acl-journal-" + Guid.NewGuid().ToString("N") + ".json")); + private bool _journalDirty; + + internal void GrantRootTraversal() => GrantOne(paths.Root, 0x100020, writable: false, inherit: false); + + internal void GrantDirectory(string directory, bool writable) + { + directory = paths.Check(directory); + if (!Directory.Exists(directory)) throw new DirectoryNotFoundException(); + var entries = new List { directory }; + for (var index = 0; index < entries.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + var entry = entries[index]; + paths.Identity(entry); + if (Directory.Exists(entry)) entries.AddRange(Directory.EnumerateFileSystemEntries(entry)); + if (entries.Count > 20000) throw new IOException("The grant exceeds the owned-entry budget."); + } + // Capture every original descriptor before an inheritable grant can propagate to children. + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + Capture(entry); + if (writable) _snapshots.Single(x => x.Path == entry).MayChangeLabel = true; + } + _journalDirty = true; + foreach (var entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + GrantOne(entry, writable ? Win32.Modify : Win32.ReadExecute, writable, inherit: writable && Directory.Exists(entry)); + } + } + + private void Capture(string path) + { + if (!_captured.Add(path)) return; + var identity = paths.Identity(path); + uint status = Win32.GetNamedSecurityInfo(path, 1, Win32.SecurityInformation, + out _, out _, out _, out _, out var descriptor); + CheckAt(status, "GetNamedSecurityInfo:DACL+LABEL", path); + try + { + Win32.Check(Win32.DescriptorToString(descriptor, 1, Win32.SecurityInformation, out var text, out _)); + try { _snapshots.Add(new(path, Marshal.PtrToStringUni(text)!, identity)); _journalDirty = true; } + finally { Win32.LocalFree(text); } + } + finally { Win32.LocalFree(descriptor); } + } + + private void GrantOne(string path, uint access, bool writable, bool inherit) + { + Capture(path); + SaveJournal(); + var snapshot = _snapshots.Single(x => x.Path == path); + CheckAt(Win32.GetNamedSecurityInfo(path, 1, Win32.DaclInformation | 1, + out var owner, out _, out var oldDacl, out _, out var descriptor), "GetNamedSecurityInfo:DACL+OWNER", path); + try + { + var entry = new Win32.ExplicitAccess + { + Permissions = access, Mode = 1, Inheritance = inherit ? 3u : 0, + Trustee = new() { Form = 0, Type = 1, Name = packageSid } + }; + Win32.CheckError(Win32.SetEntriesInAcl(1, ref entry, oldDacl, out var dacl)); + try + { + snapshot.DaclChanged = true; + CheckAt(Win32.SetNamedSecurityInfo(path, 1, Win32.DaclInformation, 0, 0, dacl, 0), "SetNamedSecurityInfo:DACL", path); + } + finally { Win32.LocalFree(dacl); } + if (writable) + { + // Changing the mandatory label requires WRITE_OWNER. Grant only the existing owner, + // only on these explicitly writable owned targets, and restore the original descriptor. + var ownerEntry = new Win32.ExplicitAccess + { + Permissions = 0x80000, Mode = 1, + Trustee = new() { Form = 0, Type = 1, Name = owner } + }; + CheckAt(Win32.GetNamedSecurityInfo(path, 1, Win32.DaclInformation, + out _, out _, out var currentDacl, out _, out var currentDescriptor), "GetNamedSecurityInfo:owner-grant", path); + try + { + Win32.CheckError(Win32.SetEntriesInAcl(1, ref ownerEntry, currentDacl, out var withOwner)); + try { CheckAt(Win32.SetNamedSecurityInfo(path, 1, Win32.DaclInformation, 0, 0, withOwner, 0), "SetNamedSecurityInfo:owner-WRITE_OWNER", path); } + finally { Win32.LocalFree(withOwner); } + } + finally { Win32.LocalFree(currentDescriptor); } + } + } + finally { Win32.LocalFree(descriptor); } + if (!writable) return; + Win32.Check(Win32.StringToDescriptor(inherit ? "S:(ML;OICI;NW;;;LW)" : "S:(ML;;NW;;;LW)", 1, out var label, out _)); + try + { + Win32.Check(Win32.GetSecurityDescriptorSacl(label, out _, out var sacl, out _)); + CheckAt(Win32.SetNamedSecurityInfo(path, 1, Win32.LabelInformation, 0, 0, 0, sacl), "SetNamedSecurityInfo:low-label", path); + snapshot.LabelChanged = true; + } + finally { Win32.LocalFree(label); } + } + + internal string? Restore() + { + string? failure = null; + // Restore ancestors first so their propagation cannot overwrite the final child descriptors. + foreach (var snapshot in _snapshots.OrderBy(x => x.Path.Length)) + { + try + { + if (!File.Exists(snapshot.Path) && !Directory.Exists(snapshot.Path)) continue; + var identity = paths.Identity(snapshot.Path); + if (identity.VolumeSerial != snapshot.Identity.VolumeSerial || identity.IndexHigh != snapshot.Identity.IndexHigh || + identity.IndexLow != snapshot.Identity.IndexLow) + throw new IOException("An ACL target changed identity during execution."); + Win32.Check(Win32.StringToDescriptor(snapshot.Sddl, 1, out var descriptor, out _)); + try + { + Win32.Check(Win32.GetSecurityDescriptorDacl(descriptor, out _, out var dacl, out _)); + Win32.Check(Win32.GetSecurityDescriptorSacl(descriptor, out _, out var sacl, out _)); + Win32.Check(Win32.GetSecurityDescriptorControl(descriptor, out var control, out _)); + // Restore a changed label while the temporary owner WRITE_OWNER grant still exists. + if (snapshot.LabelChanged) + { + EnsureOwnerLabelRight(snapshot.Path); + snapshot.DaclChanged = true; + var error = RestoreLabel(snapshot.Path, sacl); + if (error != 0) failure ??= "acl-restore:label:" + error + ":" + Relative(snapshot.Path); + } + if (snapshot.DaclChanged) + { + var protection = (control & 0x1000) != 0 ? 0x80000000u : 0x20000000u; + CheckAt(Win32.SetNamedSecurityInfo(snapshot.Path, 1, Win32.DaclInformation | protection, + 0, 0, dacl, 0), "restore-DACL", snapshot.Path); + } + } + finally { Win32.LocalFree(descriptor); } + CheckAt(Win32.GetNamedSecurityInfo(snapshot.Path, 1, Win32.SecurityInformation, + out _, out _, out _, out _, out var restored), "verify-restoration:GetNamedSecurityInfo", snapshot.Path); + try + { + Win32.Check(Win32.DescriptorToString(restored, 1, Win32.SecurityInformation, out var value, out _)); + try + { + if (!string.Equals(SecurityFingerprint(snapshot.Sddl), + SecurityFingerprint(Marshal.PtrToStringUni(value)!), StringComparison.Ordinal)) + failure ??= "acl-restore:descriptor-mismatch:" + Relative(snapshot.Path); + } + finally { Win32.LocalFree(value); } + } + finally { Win32.LocalFree(restored); } + } + catch (Exception error) when (error is System.ComponentModel.Win32Exception or IOException or UnauthorizedAccessException or ArgumentException) + { + failure ??= "acl-restore:" + Describe(error); + } + } + return failure; + } + + private static unsafe uint RestoreLabel(string path, nint originalSacl) + { + // NULL means no label update to this API. A present empty ACL removes an + // explicit label when the original descriptor contained no mandatory ACE. + var empty = stackalloc byte[8]; + new Span(empty, 8).Clear(); + empty[0] = 2; // ACL_REVISION + empty[2] = 8; // sizeof(ACL), little endian + return Win32.SetNamedSecurityInfo(path, 1, Win32.LabelInformation, 0, 0, 0, + originalSacl == 0 ? (nint)empty : originalSacl); + } + + private static string SecurityFingerprint(string sddl) + { + var descriptor = new RawSecurityDescriptor(sddl); + return descriptor.GetSddlForm(AccessControlSections.Access) + "|ML:" + MandatoryLabels(descriptor); + } + + private static string MandatoryLabels(RawSecurityDescriptor descriptor) + { + var labels = new List(); + if (descriptor.SystemAcl is { } sacl) + foreach (GenericAce ace in sacl) + { + if ((byte)ace.AceType != 17) continue; + var bytes = new byte[ace.BinaryLength]; + ace.GetBinaryForm(bytes, 0); + labels.Add(Convert.ToHexString(bytes)); + } + return string.Join(';', labels); + } + + private void EnsureOwnerLabelRight(string path) + { + CheckAt(Win32.GetNamedSecurityInfo(path, 1, Win32.DaclInformation | 1, + out var owner, out _, out var dacl, out _, out var descriptor), "restore-owner:GetNamedSecurityInfo", path); + try + { + var entry = new Win32.ExplicitAccess + { + Permissions = 0x80000, Mode = 1, + Trustee = new() { Form = 0, Type = 1, Name = owner } + }; + Win32.CheckError(Win32.SetEntriesInAcl(1, ref entry, dacl, out var restored)); + try { CheckAt(Win32.SetNamedSecurityInfo(path, 1, Win32.DaclInformation, 0, 0, restored, 0), "restore-owner:WRITE_OWNER", path); } + finally { Win32.LocalFree(restored); } + } + finally { Win32.LocalFree(descriptor); } + } + + private string Relative(string path) => Path.GetRelativePath(paths.Root, path); + + private void CheckAt(uint status, string operation, string path) + { + if (status == 0) return; + var error = new System.ComponentModel.Win32Exception((int)status); + error.Data["PocOperation"] = operation + ":" + Relative(path); + throw error; + } + + internal static string Describe(Exception error) => + (error.Data["PocOperation"] is string operation ? operation + ":" : "") + + (error is System.ComponentModel.Win32Exception win32 ? "win32-" + win32.NativeErrorCode : error.GetType().Name); + + private void SaveJournal() + { + if (!_journalDirty) return; + var temporary = _journal + ".tmp"; + using (var file = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None)) + { + using (var writer = new Utf8JsonWriter(file)) + { + writer.WriteStartObject(); writer.WriteNumber("version", 1); + writer.WriteString("root", paths.Root); writer.WriteString("profile", profileName); + writer.WriteStartArray("entries"); + foreach (var snapshot in _snapshots) + { + writer.WriteStartObject(); writer.WriteString("path", snapshot.Path); writer.WriteString("sddl", snapshot.Sddl); + writer.WriteNumber("volume", snapshot.Identity.VolumeSerial); + writer.WriteNumber("indexHigh", snapshot.Identity.IndexHigh); writer.WriteNumber("indexLow", snapshot.Identity.IndexLow); + writer.WriteBoolean("labelMayChange", snapshot.MayChangeLabel); writer.WriteEndObject(); + } + writer.WriteEndArray(); writer.WriteEndObject(); writer.Flush(); + } + file.Flush(flushToDisk: true); + } + File.Move(temporary, _journal, overwrite: true); + _journalDirty = false; + } + + internal void Complete() + { + if (File.Exists(_journal)) File.Delete(_journal); + if (File.Exists(_journal + ".tmp")) File.Delete(_journal + ".tmp"); + } + + internal static int RecoverLegacyPoCGrants(OwnedPaths paths) + { + static string ReadDacl(string path) + { + Win32.CheckError(Win32.GetNamedSecurityInfo(path, 1, Win32.DaclInformation, out _, out _, out _, out _, out var descriptor)); + try + { + Win32.Check(Win32.DescriptorToString(descriptor, 1, Win32.DaclInformation, out var value, out _)); + try { return Marshal.PtrToStringUni(value)!; } + finally { Win32.LocalFree(value); } + } + finally { Win32.LocalFree(descriptor); } + } + var rootDescriptor = new RawSecurityDescriptor(ReadDacl(paths.Root)); + var sids = new HashSet(StringComparer.Ordinal); + foreach (var ace in rootDescriptor.DiscretionaryAcl!) + if (ace is CommonAce common && common.AceQualifier == AceQualifier.AccessAllowed && + common.AccessMask == 0x100020 && common.AceFlags == AceFlags.None && + common.SecurityIdentifier.Value.StartsWith("S-1-15-2-", StringComparison.Ordinal) && + common.SecurityIdentifier.Value.Split('-').Length == 11) + sids.Add(common.SecurityIdentifier.Value); + if (sids.Count == 0) return 0; + var entries = new List { paths.Root }; + for (var index = 0; index < entries.Count; index++) + { + paths.Identity(entries[index]); + if (Directory.Exists(entries[index])) entries.AddRange(Directory.EnumerateFileSystemEntries(entries[index])); + if (entries.Count > 25000) throw new IOException("Recovery exceeds the owned-entry budget."); + } + var changes = new List<(string Path, string Sddl)>(); + var removed = 0; + foreach (var path in entries) + { + var descriptor = new RawSecurityDescriptor(ReadDacl(path)); + var dacl = descriptor.DiscretionaryAcl!; + var changed = false; + for (var index = dacl.Count - 1; index >= 0; index--) + if (dacl[index] is CommonAce common && common.AceQualifier == AceQualifier.AccessAllowed && + sids.Contains(common.SecurityIdentifier.Value) && common.AccessMask is 0x100020 or 0x1200A9 or 0x1301BF) + { dacl.RemoveAce(index); removed++; changed = true; } + if (changed) changes.Add((path, descriptor.GetSddlForm(AccessControlSections.Access))); + } + // Descendants first: retain the exact SID set at the root until their ACEs are removed. + foreach (var (path, sddl) in changes.OrderByDescending(x => x.Path.Length)) + { + paths.Identity(path); + Win32.Check(Win32.StringToDescriptor(sddl, 1, out var descriptor, out _)); + try + { + Win32.Check(Win32.GetSecurityDescriptorDacl(descriptor, out _, out var dacl, out _)); + Win32.CheckError(Win32.SetNamedSecurityInfo(path, 1, Win32.DaclInformation, 0, 0, dacl, 0)); + } + finally { Win32.LocalFree(descriptor); } + } + return removed; + } + + internal static string? RecoverPendingJournals(OwnedPaths paths) + { + foreach (var journal in Directory.EnumerateFiles(paths.Root, ".acl-journal-*.json").Order(StringComparer.Ordinal)) + { + try + { + paths.Identity(journal); + if (new FileInfo(journal).Length > 67108864) throw new IOException("The ACL journal exceeds its budget."); + using var document = JsonDocument.Parse(File.ReadAllBytes(journal), new JsonDocumentOptions { MaxDepth = 8 }); + var root = document.RootElement; + if (root.GetProperty("version").GetInt32() != 1 || root.GetProperty("root").GetString() != paths.Root) + throw new IOException("The ACL journal has a different owner root."); + var profile = root.GetProperty("profile").GetString() ?? ""; + const string prefix = "TableCloth.SkillPoc."; + if (!profile.StartsWith(prefix, StringComparison.Ordinal) || !Guid.TryParseExact(profile[prefix.Length..], "N", out _)) + throw new IOException("The ACL journal profile is invalid."); + var entries = root.GetProperty("entries"); + if (entries.GetArrayLength() > 20001) throw new IOException("The ACL journal exceeds its entry budget."); + var scope = new AclScope(paths, 0, profile, CancellationToken.None, journal); + foreach (var entry in entries.EnumerateArray()) + { + var path = paths.Check(entry.GetProperty("path").GetString()!, allowRoot: true); + if (!scope._captured.Add(path)) throw new IOException("The ACL journal contains duplicate targets."); + if (!File.Exists(path) && !Directory.Exists(path)) continue; + var identity = paths.Identity(path); + if (identity.VolumeSerial != entry.GetProperty("volume").GetUInt32() || + identity.IndexHigh != entry.GetProperty("indexHigh").GetUInt32() || identity.IndexLow != entry.GetProperty("indexLow").GetUInt32()) + throw new IOException("The ACL journal target changed identity."); + var sddl = entry.GetProperty("sddl").GetString()!; + var labelChanged = false; + if (entry.GetProperty("labelMayChange").GetBoolean()) + { + Win32.CheckError(Win32.GetNamedSecurityInfo(path, 1, Win32.LabelInformation, + out _, out _, out _, out _, out var descriptor)); + try + { + Win32.Check(Win32.DescriptorToString(descriptor, 1, Win32.LabelInformation, out var text, out _)); + try + { + labelChanged = MandatoryLabels(new RawSecurityDescriptor(Marshal.PtrToStringUni(text) ?? "")) != + MandatoryLabels(new RawSecurityDescriptor(sddl)); + } + finally { Win32.LocalFree(text); } + } + finally { Win32.LocalFree(descriptor); } + } + scope._snapshots.Add(new(path, sddl, identity) { DaclChanged = true, LabelChanged = labelChanged }); + } + var failure = scope.Restore(); + var status = Win32.DeleteAppContainerProfile(profile); + if (failure is not null) return failure; + if (status != 0 && unchecked((uint)status) is not (0x80070002 or 0x80070003 or 0x80070490)) + return "profile-recovery:hresult-" + unchecked((uint)status).ToString("X8"); + var runRoot = Path.Combine(paths.Root, ".appcontainer-runs"); + foreach (var snapshot in scope._snapshots) + if (Path.GetDirectoryName(snapshot.Path)?.Equals(runRoot, StringComparison.OrdinalIgnoreCase) == true && + Guid.TryParseExact(Path.GetFileName(snapshot.Path), "N", out _)) + paths.DeleteRunDirectory(snapshot.Path); + scope.Complete(); + } + catch (Exception error) when (error is System.ComponentModel.Win32Exception or IOException or + UnauthorizedAccessException or ArgumentException or JsonException or InvalidOperationException or KeyNotFoundException) + { return "journal-recovery:" + Describe(error); } + } + return null; + } +} diff --git a/tools/appcontainer-skill-poc/Native/AppContainerProcessRunner.cs b/tools/appcontainer-skill-poc/Native/AppContainerProcessRunner.cs new file mode 100644 index 00000000..074e03fd --- /dev/null +++ b/tools/appcontainer-skill-poc/Native/AppContainerProcessRunner.cs @@ -0,0 +1,400 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace TableCloth.AppContainerPoc; + +public sealed class AppContainerProcessRunner(string ownedRoot) +{ + // Grants and restoration cannot overlap, including across runner instances. + private static readonly SemaphoreSlim GrantGate = new(1, 1); + + public static int RecoverLegacyPoCGrants(string ownedRoot) + { + GrantGate.Wait(); + try + { + var paths = new OwnedPaths(ownedRoot); + using var lease = paths.AcquireAclLease(); + return AclScope.RecoverLegacyPoCGrants(paths); + } + finally { GrantGate.Release(); } + } + + public static string? RecoverPendingAclJournals(string ownedRoot) + { + GrantGate.Wait(); + try + { + var paths = new OwnedPaths(ownedRoot); + using var lease = paths.AcquireAclLease(); + return AclScope.RecoverPendingJournals(paths); + } + finally { GrantGate.Release(); } + } + + public async Task RunAsync(SandboxRunRequest request, CancellationToken token) + { + await GrantGate.WaitAsync(token); + try { return await RunCoreAsync(request, token); } + finally { GrantGate.Release(); } + } + + private async Task RunCoreAsync(SandboxRunRequest request, CancellationToken token) + { + OwnedPaths? paths = null; + FileStream? ownedLease = null; + Profile? profile = null; + AclScope? acl = null; + Child? child = null; + string? runDirectory = null; + var result = new SandboxRunResult(-1, "", "", false, false, false); + var stage = "validation"; + string? cleanupFailure = null; + OperationCanceledException? cancellationFailure = null; + try + { + token.ThrowIfCancellationRequested(); + paths = new(ownedRoot); + stage = "owned-root-lease"; + ownedLease = paths.AcquireAclLease(); + stage = "validation"; + if (Directory.EnumerateFiles(paths.Root, ".acl-journal-*.json").Any()) + throw new SecurityException("Pending ACL recovery must complete before another worker is launched."); + Validate(paths, request); + stage = "profile-create"; + profile = Profile.Create(request.AllowNetwork); + stage = "owned-workspace"; + var runs = paths.Check(Path.Combine(paths.Root, ".appcontainer-runs")); + Directory.CreateDirectory(runs); + runDirectory = paths.Check(Path.Combine(runs, Guid.NewGuid().ToString("N"))); + Directory.CreateDirectory(runDirectory); + foreach (var name in new[] { "home", "temp", "appdata", "localappdata" }) + Directory.CreateDirectory(Path.Combine(runDirectory, name)); + stage = "acl-grant"; + acl = new(paths, profile.Sid, profile.Name, token); + acl.GrantRootTraversal(); + foreach (var directory in request.ReadOnlyDirectories) acl.GrantDirectory(directory, writable: false); + foreach (var directory in request.ReadWriteDirectories) acl.GrantDirectory(directory, writable: true); + acl.GrantDirectory(runDirectory, writable: true); + token.ThrowIfCancellationRequested(); + stage = "appcontainer-launch"; + child = Child.Create(request, profile, EnvironmentBlock(request, runDirectory)); + result = result with { WasAppContainer = true }; + stage = "execution"; + using var timeout = new CancellationTokenSource(request.TimeoutMilliseconds); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(token, timeout.Token); + using var stop = cancellation.Token.Register(child.Terminate); + long outputBytes = 0; + var outputLimited = 0; + async Task ReadAsync(Stream stream) + { + using var saved = new MemoryStream(); + var buffer = new byte[4096]; + while (true) + { + var length = await stream.ReadAsync(buffer); + if (length == 0) break; + var total = Interlocked.Add(ref outputBytes, length); + var keep = (int)Math.Clamp(request.MaxOutputBytes - (total - length), 0, length); + if (keep != 0) saved.Write(buffer, 0, keep); + if (total <= request.MaxOutputBytes) continue; + Interlocked.Exchange(ref outputLimited, 1); + child.Terminate(); + } + return Encoding.UTF8.GetString(saved.GetBuffer(), 0, (int)saved.Length); + } + async Task WriteAsync() + { + try + { + var bytes = Encoding.UTF8.GetBytes(request.Stdin); + await child.Input.WriteAsync(bytes); + await child.Input.FlushAsync(); + } + catch (IOException) { /* A worker may exit before consuming all input. */ } + finally { child.Input.Dispose(); } + } + var stdout = ReadAsync(child.Output); + var stderr = ReadAsync(child.Error); + var input = WriteAsync(); + var wait = await Task.Run(() => Win32.WaitForSingleObject(child.Process, uint.MaxValue)); + Win32.Check(wait == 0); + // Kill any remaining descendants before waiting for inherited pipes to reach EOF. + child.Terminate(); + await Task.WhenAll(stdout, stderr, input); + Win32.Check(Win32.GetExitCodeProcess(child.Process, out var exitCode)); + result = new(unchecked((int)exitCode), await stdout, await stderr, + timeout.IsCancellationRequested && !token.IsCancellationRequested, + Volatile.Read(ref outputLimited) != 0, true); + token.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException error) when (token.IsCancellationRequested) { cancellationFailure = error; } + catch (Exception error) when (error is Win32Exception or IOException or UnauthorizedAccessException or + ArgumentException or SecurityException or PlatformNotSupportedException) + { + result = result with { Failure = stage + ":" + ErrorCode(error) }; + } + finally + { + try { child?.Dispose(); } + catch (Exception error) when (error is Win32Exception or IOException or UnauthorizedAccessException) + { cleanupFailure = "process-cleanup:" + ErrorCode(error); } + if (acl is not null) + { + var aclFailure = acl.Restore(); + cleanupFailure ??= aclFailure; + } + if (profile is not null) + { + var profileFailure = profile.Delete(); + cleanupFailure ??= profileFailure; + } + if (paths is not null && runDirectory is not null) + { + try { paths.DeleteRunDirectory(runDirectory); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException or ArgumentException) + { cleanupFailure ??= "workspace-cleanup:" + ErrorCode(error); } + } + if (cleanupFailure is null && acl is not null) + { + try { acl.Complete(); } + catch (Exception error) when (error is IOException or UnauthorizedAccessException) + { cleanupFailure = "journal-cleanup:" + ErrorCode(error); } + } + ownedLease?.Dispose(); + } + if (cancellationFailure is not null) + { + if (cleanupFailure is not null) throw new IOException("Cancellation cleanup failed: " + cleanupFailure, cancellationFailure); + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(cancellationFailure).Throw(); + } + return cleanupFailure is null ? result : result with + { + Failure = result.Failure is null ? cleanupFailure : result.Failure + ";" + cleanupFailure + }; + } + + private static string ErrorCode(Exception error) => AclScope.Describe(error); + + private static void Validate(OwnedPaths paths, SandboxRunRequest request) + { + if (request.TimeoutMilliseconds is < 1 or > 120000 || request.MemoryLimitBytes is < 16777216 or > 2147483648 || + request.MaxOutputBytes is < 1 or > 1048576 || request.MaxProcesses is < 1 or > 16 || + request.Arguments.Count > 128 || request.Arguments.Any(x => x is null || x.Length > 8192 || x.Contains('\0')) || + Encoding.UTF8.GetByteCount(request.Stdin) > 1048576 || + request.ReadOnlyDirectories.Count + request.ReadWriteDirectories.Count > 32) + throw new ArgumentException("The sandbox request exceeds its budget."); + var executable = paths.Check(request.Executable); + var working = paths.Check(request.WorkingDirectory); + paths.Identity(executable); + if (!File.Exists(executable) || !executable.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || !Directory.Exists(working)) + throw new ArgumentException("A staged executable and working directory are required."); + var read = request.ReadOnlyDirectories.Select(x => paths.Check(x)).ToArray(); + var write = request.ReadWriteDirectories.Select(x => paths.Check(x)).ToArray(); + if (read.Concat(write).Any(x => !Directory.Exists(x)) || !read.Any(x => Inside(x, executable)) || + !read.Concat(write).Any(x => Inside(x, working)) || + read.Any(ro => write.Any(rw => Inside(ro, rw) || Inside(rw, ro)))) + throw new ArgumentException("Executable access and disjoint owned grants are required."); + } + + private static bool Inside(string directory, string path) => path.Equals(directory, StringComparison.OrdinalIgnoreCase) || + path.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); + + private static string EnvironmentBlock(SandboxRunRequest request, string runDirectory) + { + var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + var environment = new SortedDictionary(StringComparer.OrdinalIgnoreCase) + { + ["SystemRoot"] = windows, ["WINDIR"] = windows, + ["PATH"] = Path.GetDirectoryName(request.Executable)!, ["PATHEXT"] = ".EXE", + ["HOME"] = Path.Combine(runDirectory, "home"), ["USERPROFILE"] = Path.Combine(runDirectory, "home"), + ["APPDATA"] = Path.Combine(runDirectory, "appdata"), ["LOCALAPPDATA"] = Path.Combine(runDirectory, "localappdata"), + ["TEMP"] = Path.Combine(runDirectory, "temp"), ["TMP"] = Path.Combine(runDirectory, "temp"), + ["PYTHONNOUSERSITE"] = "1", ["PYTHONDONTWRITEBYTECODE"] = "1", ["PYTHONUTF8"] = "1" + }; + return string.Join('\0', environment.Select(x => x.Key + "=" + x.Value)) + "\0\0"; + } + + private sealed class Profile(string name, nint sid, nint networkSid) + { + internal string Name => name; + internal nint Sid => sid; + internal nint NetworkSid => networkSid; + + internal static unsafe Profile Create(bool network) + { + var name = "TableCloth.SkillPoc." + Guid.NewGuid().ToString("N"); + nint networkSid = 0; + try + { + if (network) Win32.Check(Win32.ConvertStringSidToSid("S-1-15-3-1", out networkSid)); + var capability = new Win32.SidAndAttributes { Sid = networkSid, Attributes = 4 }; + var status = Win32.CreateAppContainerProfile(name, name, "Dedicated TableCloth skill PoC worker", + network ? &capability : null, network ? 1u : 0, out var sid); + if (status != 0) + { + if (sid != 0) Win32.FreeSid(sid); + throw new Win32Exception(status); + } + return new(name, sid, networkSid); + } + catch + { + if (networkSid != 0) Win32.LocalFree(networkSid); + throw; + } + } + + internal string? Delete() + { + var status = Win32.DeleteAppContainerProfile(name); + Win32.FreeSid(sid); + if (networkSid != 0) Win32.LocalFree(networkSid); + return status == 0 ? null : "profile-delete:hresult-" + unchecked((uint)status).ToString("X8"); + } + } + + private sealed class Child(SafeFileHandle process, SafeFileHandle thread, SafeFileHandle job, + FileStream input, FileStream output, FileStream error) : IDisposable + { + private int _terminationError; + internal SafeFileHandle Process => process; + internal FileStream Input => input; + internal FileStream Output => output; + internal FileStream Error => error; + + internal void Terminate() + { + if (!job.IsClosed && !Win32.TerminateJobObject(job, 0xC000013A)) + Interlocked.CompareExchange(ref _terminationError, Marshal.GetLastPInvokeError(), 0); + } + + public void Dispose() + { + Terminate(); + job.Dispose(); + var wait = Win32.WaitForSingleObject(process, 5000); + var waitError = wait == uint.MaxValue ? Marshal.GetLastPInvokeError() : (int)wait; + try { input.Dispose(); output.Dispose(); error.Dispose(); } + finally { thread.Dispose(); process.Dispose(); } + if (wait != 0) throw new Win32Exception(waitError); + if (_terminationError != 0) throw new Win32Exception(_terminationError); + } + + internal static unsafe Child Create(SandboxRunRequest request, Profile profile, string environment) + { + var attributes = new Win32.SecurityAttributes { Length = (uint)sizeof(Win32.SecurityAttributes), Inherit = 1 }; + SafeFileHandle? inputRead = null, inputWrite = null, outputRead = null, outputWrite = null, + errorRead = null, errorWrite = null, process = null, thread = null, job = null; + nint list = 0; + var listReady = false; + var success = false; + try + { + Win32.Check(Win32.CreatePipe(out inputRead, out inputWrite, ref attributes, 0)); + Win32.Check(Win32.CreatePipe(out outputRead, out outputWrite, ref attributes, 0)); + Win32.Check(Win32.CreatePipe(out errorRead, out errorWrite, ref attributes, 0)); + Win32.Check(Win32.SetHandleInformation(inputWrite, 1, 0)); + Win32.Check(Win32.SetHandleInformation(outputRead, 1, 0)); + Win32.Check(Win32.SetHandleInformation(errorRead, 1, 0)); + job = Win32.CreateJobObject(0, 0); + if (job.IsInvalid) throw new Win32Exception(Marshal.GetLastPInvokeError()); + var limits = new Win32.ExtendedLimits + { + Basic = new() + { + Flags = 0x2000 | 0x8 | 0x100 | 0x200 | 0x400 | 0x2 | 0x4, + ActiveProcesses = (uint)request.MaxProcesses, + ProcessTime = request.TimeoutMilliseconds * 10000L, + JobTime = request.TimeoutMilliseconds * 10000L + }, + ProcessMemory = (nuint)request.MemoryLimitBytes, JobMemory = (nuint)request.MemoryLimitBytes + }; + Win32.Check(Win32.SetInformationJobObject(job, 9, &limits, (uint)sizeof(Win32.ExtendedLimits))); + nuint size = 0; + Win32.InitializeProcThreadAttributeList(0, 2, 0, ref size); + if (size == 0) throw new Win32Exception(Marshal.GetLastPInvokeError()); + list = (nint)NativeMemory.Alloc(size); + Win32.Check(Win32.InitializeProcThreadAttributeList(list, 2, 0, ref size)); + listReady = true; + var capability = new Win32.SidAndAttributes { Sid = profile.NetworkSid, Attributes = 4 }; + var capabilities = new Win32.SecurityCapabilities + { + AppContainerSid = profile.Sid, + Capabilities = profile.NetworkSid == 0 ? 0 : (nint)(&capability), + CapabilityCount = profile.NetworkSid == 0 ? 0u : 1 + }; + Win32.Check(Win32.UpdateProcThreadAttribute(list, 0, 0x20009, &capabilities, + (nuint)sizeof(Win32.SecurityCapabilities), 0, 0)); + var handles = stackalloc nint[] { inputRead.DangerousGetHandle(), outputWrite.DangerousGetHandle(), errorWrite.DangerousGetHandle() }; + Win32.Check(Win32.UpdateProcThreadAttribute(list, 0, 0x20002, handles, (nuint)(3 * sizeof(nint)), 0, 0)); + var startup = new Win32.StartupInfoEx + { + Startup = new() + { + Size = (uint)sizeof(Win32.StartupInfoEx), Flags = 0x100, + Input = handles[0], Output = handles[1], Error = handles[2] + }, + Attributes = list + }; + var command = string.Join(' ', new[] { request.Executable }.Concat(request.Arguments).Select(Quote)) + '\0'; + if (command.Length > 32767) throw new ArgumentException("The command line exceeds the Win32 limit."); + fixed (char* commandLine = command) + fixed (char* variables = environment) + { + Win32.Check(Win32.CreateProcess(request.Executable, commandLine, 0, 0, true, + 0x00080000 | 0x00000400 | 0x00000004 | 0x08000000, + variables, request.WorkingDirectory, ref startup, out var information)); + process = new(information.Process, ownsHandle: true); + thread = new(information.Thread, ownsHandle: true); + } + Win32.Check(Win32.OpenProcessToken(process, 8, out var token)); + using (token) + { + Win32.Check(Win32.GetTokenInformation(token, 29, out var isContainer, 4, out _)); + if (isContainer != 1) throw new SecurityException("The worker token is not an AppContainer."); + } + Win32.Check(Win32.AssignProcessToJobObject(job, process)); + var child = new Child(process, thread, job, + new FileStream(inputWrite, FileAccess.Write), new FileStream(outputRead, FileAccess.Read), + new FileStream(errorRead, FileAccess.Read)); + Win32.Check(Win32.ResumeThread(thread) != uint.MaxValue); + success = true; + return child; + } + finally + { + if (listReady) Win32.DeleteProcThreadAttributeList(list); + if (list != 0) NativeMemory.Free((void*)list); + inputRead?.Dispose(); outputWrite?.Dispose(); errorWrite?.Dispose(); + if (!success) + { + if (process is not null && !process.IsInvalid) + { + Win32.TerminateProcess(process, 0xC000013A); + Win32.WaitForSingleObject(process, 5000); + } + process?.Dispose(); thread?.Dispose(); job?.Dispose(); + inputWrite?.Dispose(); outputRead?.Dispose(); errorRead?.Dispose(); + } + } + } + + private static string Quote(string value) + { + var result = new StringBuilder("\""); + var slashes = 0; + foreach (var character in value) + { + if (character == '\\') { slashes++; continue; } + result.Append('\\', character == '"' ? slashes * 2 + 1 : slashes); + result.Append(character); + slashes = 0; + } + return result.Append('\\', slashes * 2).Append('"').ToString(); + } + } +} diff --git a/tools/appcontainer-skill-poc/Native/OwnedPaths.cs b/tools/appcontainer-skill-poc/Native/OwnedPaths.cs new file mode 100644 index 00000000..649be6d7 --- /dev/null +++ b/tools/appcontainer-skill-poc/Native/OwnedPaths.cs @@ -0,0 +1,83 @@ +using System.ComponentModel; + +namespace TableCloth.AppContainerPoc; + +internal sealed class OwnedPaths +{ + internal string Root { get; } + + internal OwnedPaths(string root) + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10)) throw new PlatformNotSupportedException(); + if (!Path.IsPathFullyQualified(root) || root.StartsWith("\\\\", StringComparison.Ordinal)) + throw new ArgumentException("A local absolute owned root is required."); + Root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + if (Root == Path.GetPathRoot(Root) || !Directory.Exists(Root)) + throw new ArgumentException("The dedicated owned root must already exist."); + Check(Root, allowRoot: true); + } + + internal string Check(string path, bool allowRoot = false) + { + if (!Path.IsPathFullyQualified(path) || path.StartsWith("\\\\", StringComparison.Ordinal)) + throw new ArgumentException("Only local absolute owned paths are permitted."); + path = Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + if (path.AsSpan(2).Contains(':') || path.Split(Path.DirectorySeparatorChar).Skip(1) + .Any(segment => segment.EndsWith('.') || segment.EndsWith(' '))) + throw new ArgumentException("Alternate streams and ambiguous Win32 path segments are not permitted."); + if (!(allowRoot && path.Equals(Root, StringComparison.OrdinalIgnoreCase)) && + !path.StartsWith(Root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + throw new ArgumentException("A path is outside the dedicated owned root."); + for (var current = new DirectoryInfo(path); current is not null; current = current.Parent) + if ((File.Exists(current.FullName) || Directory.Exists(current.FullName)) && + (File.GetAttributes(current.FullName) & FileAttributes.ReparsePoint) != 0) + throw new IOException("Reparse points are not permitted."); + return path; + } + + internal FileStream AcquireAclLease() + { + var path = Check(Path.Combine(Root, ".appcontainer-acl.lock")); + if (File.Exists(path)) Identity(path); + return new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + } + + internal Win32.FileInformation Identity(string path) + { + Check(path, allowRoot: true); + using var handle = Win32.OpenMetadata(path, 0, 7, 0, 3, 0x02200000, 0); + if (handle.IsInvalid) throw new Win32Exception(System.Runtime.InteropServices.Marshal.GetLastPInvokeError()); + Win32.Check(Win32.GetFileInformationByHandle(handle, out var identity)); + if ((identity.Attributes & (uint)FileAttributes.ReparsePoint) != 0 || + (identity.Attributes & (uint)FileAttributes.Directory) == 0 && identity.Links != 1) + throw new IOException("Reparse points and hard-linked files are not permitted."); + return identity; + } + + internal void DeleteRunDirectory(string path) + { + Check(path); + if (!Directory.Exists(path)) return; + DeleteContents(path); + Directory.Delete(path); + } + + private static void DeleteContents(string directory) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(directory)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.Directory) != 0) + { + if ((attributes & FileAttributes.ReparsePoint) == 0) DeleteContents(entry); + Directory.Delete(entry); + } + else + { + if ((attributes & FileAttributes.ReadOnly) != 0 && (attributes & FileAttributes.ReparsePoint) == 0) + File.SetAttributes(entry, attributes & ~FileAttributes.ReadOnly); + File.Delete(entry); + } + } + } +} diff --git a/tools/appcontainer-skill-poc/Native/SandboxRunContracts.cs b/tools/appcontainer-skill-poc/Native/SandboxRunContracts.cs new file mode 100644 index 00000000..f92573a5 --- /dev/null +++ b/tools/appcontainer-skill-poc/Native/SandboxRunContracts.cs @@ -0,0 +1,23 @@ +namespace TableCloth.AppContainerPoc; + +public sealed record SandboxRunRequest( + string Executable, + IReadOnlyList Arguments, + string WorkingDirectory, + string Stdin, + IReadOnlyList ReadOnlyDirectories, + IReadOnlyList ReadWriteDirectories, + bool AllowNetwork = false, + int TimeoutMilliseconds = 15000, + long MemoryLimitBytes = 268435456, + int MaxOutputBytes = 65536, + int MaxProcesses = 1); + +public sealed record SandboxRunResult( + int ExitCode, + string Stdout, + string Stderr, + bool TimedOut, + bool OutputLimited, + bool WasAppContainer, + string? Failure = null); diff --git a/tools/appcontainer-skill-poc/Native/Win32.cs b/tools/appcontainer-skill-poc/Native/Win32.cs new file mode 100644 index 00000000..ff421021 --- /dev/null +++ b/tools/appcontainer-skill-poc/Native/Win32.cs @@ -0,0 +1,248 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace TableCloth.AppContainerPoc; + +internal static unsafe partial class Win32 +{ + internal const uint DaclInformation = 4, LabelInformation = 16; + internal const uint SecurityInformation = DaclInformation | LabelInformation; + internal const uint ReadExecute = 0x1200A9, Modify = 0x1301BF; + + internal static void Check(bool success) + { + if (!success) throw new Win32Exception(Marshal.GetLastPInvokeError()); + } + + internal static void CheckError(uint error) + { + if (error != 0) throw new Win32Exception((int)error); + } + + [StructLayout(LayoutKind.Sequential)] + internal struct SecurityAttributes + { + internal uint Length; + internal nint Descriptor; + internal int Inherit; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct StartupInfo + { + internal uint Size; + internal nint Reserved, Desktop, Title; + internal uint X, Y, Width, Height, XChars, YChars, Fill, Flags; + internal ushort ShowWindow, ReservedSize; + internal nint ReservedBytes, Input, Output, Error; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct StartupInfoEx + { + internal StartupInfo Startup; + internal nint Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct ProcessInformation + { + internal nint Process, Thread; + internal uint ProcessId, ThreadId; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct SidAndAttributes + { + internal nint Sid; + internal uint Attributes; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct SecurityCapabilities + { + internal nint AppContainerSid, Capabilities; + internal uint CapabilityCount, Reserved; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct BasicLimits + { + internal long ProcessTime, JobTime; + internal uint Flags; + internal nuint MinimumWorkingSet, MaximumWorkingSet; + internal uint ActiveProcesses; + internal nuint Affinity; + internal uint Priority, SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct IoCounters + { + internal ulong ReadOps, WriteOps, OtherOps, ReadBytes, WriteBytes, OtherBytes; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct ExtendedLimits + { + internal BasicLimits Basic; + internal IoCounters Io; + internal nuint ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct Trustee + { + internal nint MultipleTrustee; + internal uint MultipleTrusteeOperation, Form, Type; + internal nint Name; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct ExplicitAccess + { + internal uint Permissions, Mode, Inheritance; + internal Trustee Trustee; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct FileTime + { + internal uint Low, High; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct FileInformation + { + internal uint Attributes; + internal FileTime Created, Accessed, Written; + internal uint VolumeSerial, SizeHigh, SizeLow, Links, IndexHigh, IndexLow; + } + + [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int CreateAppContainerProfile(string name, string displayName, string description, + SidAndAttributes* capabilities, uint count, out nint sid); + + [LibraryImport("userenv.dll", StringMarshalling = StringMarshalling.Utf16)] + internal static partial int DeleteAppContainerProfile(string name); + + [LibraryImport("advapi32.dll")] + internal static partial nint FreeSid(nint sid); + + [LibraryImport("kernel32.dll")] + internal static partial nint LocalFree(nint memory); + + [LibraryImport("advapi32.dll", EntryPoint = "ConvertStringSidToSidW", SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool ConvertStringSidToSid(string value, out nint sid); + + [LibraryImport("advapi32.dll", EntryPoint = "GetNamedSecurityInfoW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial uint GetNamedSecurityInfo(string path, uint type, uint information, + out nint owner, out nint group, out nint dacl, out nint sacl, out nint descriptor); + + [LibraryImport("advapi32.dll", EntryPoint = "SetNamedSecurityInfoW", StringMarshalling = StringMarshalling.Utf16)] + internal static partial uint SetNamedSecurityInfo(string path, uint type, uint information, + nint owner, nint group, nint dacl, nint sacl); + + [LibraryImport("advapi32.dll", EntryPoint = "ConvertSecurityDescriptorToStringSecurityDescriptorW", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool DescriptorToString(nint descriptor, uint revision, uint information, + out nint value, out uint length); + + [LibraryImport("advapi32.dll", EntryPoint = "ConvertStringSecurityDescriptorToSecurityDescriptorW", SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool StringToDescriptor(string value, uint revision, out nint descriptor, out uint length); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetSecurityDescriptorDacl(nint descriptor, out int present, out nint dacl, out int defaulted); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetSecurityDescriptorSacl(nint descriptor, out int present, out nint sacl, out int defaulted); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetSecurityDescriptorControl(nint descriptor, out ushort control, out uint revision); + + [LibraryImport("advapi32.dll", EntryPoint = "SetEntriesInAclW")] + internal static partial uint SetEntriesInAcl(uint count, ref ExplicitAccess entry, nint oldAcl, out nint newAcl); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CreatePipe(out SafeFileHandle read, out SafeFileHandle write, + ref SecurityAttributes attributes, uint size); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetHandleInformation(SafeFileHandle handle, uint mask, uint flags); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool InitializeProcThreadAttributeList(nint list, int count, uint flags, ref nuint size); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool UpdateProcThreadAttribute(nint list, uint flags, nuint attribute, + void* value, nuint size, nint previous, nint returnedSize); + + [LibraryImport("kernel32.dll")] + internal static partial void DeleteProcThreadAttributeList(nint list); + + [LibraryImport("kernel32.dll", EntryPoint = "CreateProcessW", SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool CreateProcess(string application, char* commandLine, nint processSecurity, + nint threadSecurity, [MarshalAs(UnmanagedType.Bool)] bool inherit, uint flags, char* environment, + string directory, ref StartupInfoEx startup, out ProcessInformation process); + + [LibraryImport("kernel32.dll", EntryPoint = "CreateJobObjectW", SetLastError = true)] + internal static partial SafeFileHandle CreateJobObject(nint attributes, nint name); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool SetInformationJobObject(SafeFileHandle job, int infoClass, void* info, uint length); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool AssignProcessToJobObject(SafeFileHandle job, SafeFileHandle process); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool TerminateJobObject(SafeFileHandle job, uint code); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool TerminateProcess(SafeFileHandle process, uint code); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint ResumeThread(SafeFileHandle thread); + + [LibraryImport("kernel32.dll", SetLastError = true)] + internal static partial uint WaitForSingleObject(SafeFileHandle handle, uint milliseconds); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetExitCodeProcess(SafeFileHandle process, out uint code); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool OpenProcessToken(SafeFileHandle process, uint access, out SafeFileHandle token); + + [LibraryImport("advapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetTokenInformation(SafeFileHandle token, int informationClass, + out int value, uint length, out uint returned); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static partial bool GetFileInformationByHandle(SafeFileHandle file, out FileInformation information); + + [LibraryImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, + StringMarshalling = StringMarshalling.Utf16)] + internal static partial SafeFileHandle OpenMetadata(string path, uint access, uint share, nint security, + uint creation, uint flags, nint template); +} diff --git a/tools/appcontainer-skill-poc/Poc.csproj b/tools/appcontainer-skill-poc/Poc.csproj new file mode 100644 index 00000000..5a5911f4 --- /dev/null +++ b/tools/appcontainer-skill-poc/Poc.csproj @@ -0,0 +1,10 @@ + + + Exe + net11.0-windows10.0.18362.0 + enable + enable + true + TableCloth.AppContainerPoc + + diff --git a/tools/appcontainer-skill-poc/Preparation.cs b/tools/appcontainer-skill-poc/Preparation.cs new file mode 100644 index 00000000..8ba2120b --- /dev/null +++ b/tools/appcontainer-skill-poc/Preparation.cs @@ -0,0 +1,137 @@ +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text.Json; + +namespace TableCloth.AppContainerPoc; + +internal static class Preparation +{ + internal static async Task RunAsync(string workspace, CancellationToken token) + { + workspace = Path.GetFullPath(workspace); + var destination = Path.Combine(workspace, "Artifacts", "appcontainer-skill-poc"); + var sources = Path.Combine(workspace, "Artifacts", "skill-runtime-research-20261025"); + var pythonZip = Path.Combine(sources, "python-3.14.9-embed-amd64.zip"); + var nodeZip = Path.Combine(sources, "node-v24.22.1-win-x64.zip"); + await EnsureArchiveAsync(pythonZip, "https://www.python.org/ftp/python/3.14.9/python-3.14.9-embed-amd64.zip", "3c01d7cfc666f746a6f064f60fb2156ab0ec464d3d738ab796371a46a9bb24a2", token); + await EnsureArchiveAsync(nodeZip, "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip", "d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5", token); + Directory.CreateDirectory(destination); + var owned = new OwnedPaths(destination); + using var lease = owned.AcquireAclLease(); + if (Directory.EnumerateFiles(destination, ".acl-journal-*.json").Any()) throw new InvalidOperationException("Recover pending ACL journals before preparation."); + foreach (var temporaryDirectory in new[] { "probe-work", "public-api-work" }) + owned.DeleteRunDirectory(Under(destination, temporaryDirectory)); + await ExtractAsync(pythonZip, Path.Combine(destination, "runtime", "python"), "", token); + await ExtractAsync(nodeZip, Path.Combine(destination, "runtime", "node"), "node-v24.22.1-win-x64/", token); + CopyTree(Path.Combine(workspace, "tools", "appcontainer-skill-poc", "skills"), Path.Combine(destination, "skills")); + CopyTree(Path.Combine(workspace, "tools", "appcontainer-skill-poc", "Probes"), Path.Combine(destination, "probes")); + var python = Path.Combine(destination, "runtime", "python", "python.exe"); + var node = Path.Combine(destination, "runtime", "node", "node.exe"); + var calculator = Path.Combine(destination, "skills", "tablecloth-calculators", "scripts", "calculate.py"); + var kosis = Path.Combine(destination, "skills", "kosis-stats", "scripts", "run_kosis_stats.py"); + var bok = Path.Combine(destination, "skills", "bok-ecos-stats", "scripts", "bok_ecos.py"); + var manifest = new + { + ownedRoot = destination, pythonExecutable = "runtime/python/python.exe", pythonSha256 = Hash(python), + nodeExecutable = "runtime/node/node.exe", nodeSha256 = Hash(node), + skills = new object[] + { + new { skillId = "tablecloth-calculators", kind = "python-json-stdin", scriptPath = "skills/tablecloth-calculators/scripts/calculate.py", sha256 = Hash(calculator), + allowedOperations = new[] { "discount.single", "percent.part", "date.add", "time.convert", "bmi", "loan" }, requiresNetwork = false }, + new { skillId = "kosis-stats", kind = "python-cli-fixed", scriptPath = "skills/kosis-stats/scripts/run_kosis_stats.py", sha256 = Hash(kosis), + fixedArguments = new[] { "search", "--query", "인구", "--result-count", "1", "--json", "--dry-run" }, requiresNetwork = false }, + new { skillId = "bok-ecos-stats", kind = "python-cli-fixed", scriptPath = "skills/bok-ecos-stats/scripts/bok_ecos.py", sha256 = Hash(bok), + fixedArguments = new[] { "key", "--limit", "1" }, requiresNetwork = true }, + new { skillId = "node-runtime-fixture", kind = "node-json-stdin", scriptPath = "probes/node-json.cjs", sha256 = Hash(Path.Combine(destination, "probes", "node-json.cjs")), requiresNetwork = false } + } + }; + await File.WriteAllTextAsync(Path.Combine(destination, "evaluation-manifest.json"), JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true }), token); + Console.WriteLine("Prepared isolated PoC runtimes and pinned skills: " + destination); + return 0; + } + internal static string Hash(string path) => Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(path))); + private static async Task EnsureArchiveAsync(string path, string url, string expected, CancellationToken token) + { + RejectReparse(path); + if (!File.Exists(path)) + { + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + var partial = path + ".download-" + Guid.NewGuid().ToString("N"); + try + { + using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromMinutes(3) }; + using var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, token); + response.EnsureSuccessStatusCode(); + if (response.Content.Headers.ContentLength is > 100_000_000) throw new InvalidDataException("Runtime archive too large"); + await using (var input = await response.Content.ReadAsStreamAsync(token)) + await using (var output = new FileStream(partial, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + var buffer = new byte[65536]; long size = 0; int count; + while ((count = await input.ReadAsync(buffer, token)) != 0) + { + if ((size += count) > 100_000_000) throw new InvalidDataException("Runtime archive too large"); + await output.WriteAsync(buffer.AsMemory(0, count), token); + } + } + Verify(partial, expected); + File.Move(partial, path); + } + finally { if (File.Exists(partial)) File.Delete(partial); } + } + Verify(path, expected); + } + private static void Verify(string path, string expected) + { + RejectReparse(path); + if (Hash(path) != expected) throw new InvalidDataException("Runtime archive hash mismatch: " + Path.GetFileName(path)); + } + private static async Task ExtractAsync(string archive, string directory, string prefix, CancellationToken token) + { + Directory.CreateDirectory(directory); RejectReparse(directory); + using var zip = ZipFile.OpenRead(archive); + long expanded = 0; + foreach (var entry in zip.Entries) + { + token.ThrowIfCancellationRequested(); + var name = entry.FullName.Replace('\\', '/'); + if (prefix.Length != 0 && !name.StartsWith(prefix, StringComparison.Ordinal)) throw new InvalidDataException("Archive prefix mismatch"); + name = name[prefix.Length..]; + if (name.Length == 0) continue; + if (name.StartsWith('/') || name.Contains(':') || name.Split('/').Any(x => x == ".." || x == ".")) throw new InvalidDataException("Unsafe archive path"); + if ((expanded += entry.Length) > 600L * 1024 * 1024) throw new InvalidDataException("Archive too large"); + var target = Under(directory, name.Replace('/', Path.DirectorySeparatorChar)); + RejectReparse(target); + if (name.EndsWith('/')) Directory.CreateDirectory(target); + else + { + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + await using var input = entry.Open(); await using var output = new FileStream(target, FileMode.Create, FileAccess.Write, FileShare.None); + await input.CopyToAsync(output, token); + } + } + } + private static void CopyTree(string source, string destination) + { + RejectReparse(source); Directory.CreateDirectory(destination); RejectReparse(destination); + foreach (var child in Directory.EnumerateFileSystemEntries(source)) + { + RejectReparse(child); + var target = Under(destination, Path.GetFileName(child)); + if (Directory.Exists(child)) CopyTree(child, target); + else { RejectReparse(target); File.Copy(child, target, true); } + } + } + internal static string Under(string root, string path) + { + root = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar); + var target = Path.GetFullPath(Path.Combine(root, path)); + if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Path escaped owned directory"); + RejectReparse(target); return target; + } + internal static void RejectReparse(string path) + { + for (var node = new DirectoryInfo(Path.GetFullPath(path)); node is not null; node = node.Parent) + if ((File.Exists(node.FullName) || Directory.Exists(node.FullName)) && (File.GetAttributes(node.FullName) & FileAttributes.ReparsePoint) != 0) + throw new InvalidDataException("Reparse point is not allowed"); + } +} diff --git a/tools/appcontainer-skill-poc/Probes/isolation.cjs b/tools/appcontainer-skill-poc/Probes/isolation.cjs new file mode 100644 index 00000000..d7ee29fd --- /dev/null +++ b/tools/appcontainer-skill-poc/Probes/isolation.cjs @@ -0,0 +1,34 @@ +'use strict'; +const fs = require('node:fs'); +const net = require('node:net'); +const cp = require('node:child_process'); +let text = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', part => { text += part; }); +process.stdin.on('end', async () => { + const input = JSON.parse(text); const result = {}; + try { result.outside_read = {allowed: true, content: fs.readFileSync(input.outside_read, 'utf8')}; } + catch (error) { result.outside_read = {allowed: false, code: error.code}; } + try { fs.writeFileSync(input.outside_write, 'unexpected'); result.outside_write = {allowed: true}; } + catch (error) { result.outside_write = {allowed: false, code: error.code}; } + result.network = await new Promise(resolve => { + const client = net.connect({host: '1.1.1.1', port: 443}); + client.setTimeout(3000); + client.once('connect', () => { client.destroy(); resolve({allowed: true}); }); + client.once('error', error => { client.destroy(); resolve({allowed: false, code: error.code}); }); + client.once('timeout', () => { client.destroy(); resolve({allowed: false, code: 'TIMEOUT'}); }); + }); + result.child = await new Promise(resolve => { + let child; + try { child = cp.spawn(process.execPath, ['--version'], {stdio: 'ignore'}); } + catch (error) { resolve({allowed: false, code: error.code, errno: error.errno}); return; } + let done = false; + const finish = outcome => { if (done) return; done = true; clearTimeout(timer); resolve(outcome); }; + const timer = setTimeout(() => { child.kill(); finish({allowed: true, code: 'TIMEOUT'}); }, 3000); + child.once('error', error => finish({allowed: false, code: error.code, errno: error.errno})); + child.once('exit', code => finish({allowed: true, exit: code})); + }); + result.inherited_secret = process.env.TABLECLOTH_POC_SYNTHETIC_SECRET ?? null; + fs.writeFileSync('allowed-write.txt', 'allowed'); result.work_write = true; + console.log(JSON.stringify(result)); +}); diff --git a/tools/appcontainer-skill-poc/Probes/isolation.py b/tools/appcontainer-skill-poc/Probes/isolation.py new file mode 100644 index 00000000..c64beadf --- /dev/null +++ b/tools/appcontainer-skill-poc/Probes/isolation.py @@ -0,0 +1,37 @@ +"""Adversarial checks against synthetic, runner-owned fixtures only.""" +import json +import os +import socket +import subprocess +import sys + +request = json.loads(sys.stdin.read()) +result = {} +try: + with open(request['outside_read'], encoding='utf-8') as file: + result['outside_read'] = {'allowed': True, 'content': file.read(32)} +except OSError as error: + result['outside_read'] = {'allowed': False, 'winerror': getattr(error, 'winerror', None), 'errno': error.errno} +try: + with open(request['outside_write'], 'w', encoding='utf-8') as file: + file.write('unexpected') + result['outside_write'] = {'allowed': True} +except OSError as error: + result['outside_write'] = {'allowed': False, 'winerror': getattr(error, 'winerror', None), 'errno': error.errno} +try: + with socket.create_connection(('1.1.1.1', 443), timeout=3): + result['network'] = {'allowed': True} +except OSError as error: + result['network'] = {'allowed': False, 'winerror': getattr(error, 'winerror', None), 'errno': error.errno} +try: + outcome = subprocess.run([sys.executable, '-I', '-S', '-B', '-c', 'print("child")'], capture_output=True, timeout=3) + result['child'] = {'allowed': True, 'exit': outcome.returncode} +except OSError as error: + result['child'] = {'allowed': False, 'winerror': getattr(error, 'winerror', None)} +except subprocess.TimeoutExpired: + result['child'] = {'allowed': True, 'timed_out': True} +result['inherited_secret'] = os.environ.get('TABLECLOTH_POC_SYNTHETIC_SECRET') +with open('allowed-write.txt', 'w', encoding='utf-8') as file: + file.write('allowed') +result['work_write'] = True +print(json.dumps(result)) diff --git a/tools/appcontainer-skill-poc/Probes/memory.py b/tools/appcontainer-skill-poc/Probes/memory.py new file mode 100644 index 00000000..ac35f501 --- /dev/null +++ b/tools/appcontainer-skill-poc/Probes/memory.py @@ -0,0 +1,8 @@ +"""Allocate beyond the configured 64 MiB job limit using synthetic data.""" +import json + +try: + allocation = bytearray(256 * 1024 * 1024) + print(json.dumps({'allocation_succeeded': True})) +except MemoryError: + print(json.dumps({'allocation_succeeded': False, 'memory_error': True})) diff --git a/tools/appcontainer-skill-poc/Probes/node-json.cjs b/tools/appcontainer-skill-poc/Probes/node-json.cjs new file mode 100644 index 00000000..db43af5b --- /dev/null +++ b/tools/appcontainer-skill-poc/Probes/node-json.cjs @@ -0,0 +1,9 @@ +'use strict'; +let text = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', part => { text += part; if (text.length > 8192) process.exit(2); }); +process.stdin.on('end', () => { + const value = JSON.parse(text); + if (!Number.isFinite(value.a) || !Number.isFinite(value.b)) process.exit(2); + process.stdout.write(JSON.stringify({ok: true, sum: value.a + value.b, runtime: process.version})); +}); diff --git a/tools/appcontainer-skill-poc/Probes/output.py b/tools/appcontainer-skill-poc/Probes/output.py new file mode 100644 index 00000000..14a9f640 --- /dev/null +++ b/tools/appcontainer-skill-poc/Probes/output.py @@ -0,0 +1,4 @@ +import sys +while True: + sys.stdout.write('x' * 4096 + '\n') + sys.stdout.flush() diff --git a/tools/appcontainer-skill-poc/Probes/unicode.py b/tools/appcontainer-skill-poc/Probes/unicode.py new file mode 100644 index 00000000..e64341d9 --- /dev/null +++ b/tools/appcontainer-skill-poc/Probes/unicode.py @@ -0,0 +1,5 @@ +import json +import sys + +value = json.loads(sys.stdin.read()) +print(json.dumps({'text': value['text'], 'stdout_encoding': sys.stdout.encoding}, ensure_ascii=False)) diff --git a/tools/appcontainer-skill-poc/Probes/wait.py b/tools/appcontainer-skill-poc/Probes/wait.py new file mode 100644 index 00000000..1ae8741c --- /dev/null +++ b/tools/appcontainer-skill-poc/Probes/wait.py @@ -0,0 +1,2 @@ +import time +time.sleep(60) diff --git a/tools/appcontainer-skill-poc/Program.cs b/tools/appcontainer-skill-poc/Program.cs new file mode 100644 index 00000000..e5512896 --- /dev/null +++ b/tools/appcontainer-skill-poc/Program.cs @@ -0,0 +1,27 @@ +using TableCloth.AppContainerPoc; + +using var stop = new CancellationTokenSource(); +Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); }; +try +{ + if (args is ["prepare", var workspace]) return await Preparation.RunAsync(workspace, stop.Token); + if (args is ["mcp", var manifest]) return await SkillMcpBroker.RunAsync(manifest, stop.Token); + if (args is ["selftest", var root]) return await Evaluation.RunAsync(root, includePublicApi: false, stop.Token); + if (args is ["evaluate-public-api", var root2]) return await Evaluation.RunAsync(root2, includePublicApi: true, stop.Token); + if (args is ["codex-probe", var root3]) return await CodexProbe.RunAsync(root3, stop.Token); + if (args is ["recover-legacy-acl", var root4]) + { + Console.WriteLine(AppContainerProcessRunner.RecoverLegacyPoCGrants(root4)); + return 0; + } + if (args is ["recover-acl", var root5]) + { + var failure = AppContainerProcessRunner.RecoverPendingAclJournals(root5); + Console.WriteLine(failure ?? "ACL journals recovered."); + return failure is null ? 0 : 1; + } + Console.Error.WriteLine("Commands: prepare , mcp , selftest , evaluate-public-api , codex-probe "); + return args.Length == 0 || args[0] == "--help" ? 0 : 2; +} +catch (OperationCanceledException) { Console.Error.WriteLine("Canceled"); return 130; } +catch (Exception e) { Console.Error.WriteLine(e.GetType().Name + ": " + e.Message); return 1; } diff --git a/tools/appcontainer-skill-poc/README.ko.md b/tools/appcontainer-skill-poc/README.ko.md new file mode 100644 index 00000000..f231a877 --- /dev/null +++ b/tools/appcontainer-skill-poc/README.ko.md @@ -0,0 +1,71 @@ +# AppContainer 스킬 실행 PoC + +[English](README.md) + +2026년 10월 25일 기준으로 이 브랜치에서 일반 Windows AppContainer 안의 Python과 Node.js 스크립트 실행을 평가합니다. 식탁보 제품 코드는 기존 `shell_tool=false`를 유지하며 이 중계기나 제거했던 전용 런타임 기능을 자동으로 활성화하지 않습니다. + +이번 실험은 네이티브 작업 프로세스, 고정 스크립트 MCP 중계기, 고정 원본 스킬과 평가용 카탈로그를 포함합니다. 중계기 입력 검증, 실제 격리 실행, 외부 서비스 호출과 Codex 도구 연결을 구분해 기록합니다. + +실행 경계를 먼저 살펴보겠습니다. 이어서 재현 명령, 스킬별 어댑터, 측정 결과와 제품 통합 범위를 정리합니다. + +## 호스트 중계기와 AppContainer 작업 프로세스 + +신뢰하는 호스트 중계기는 등록한 스킬 ID와 JSON 입력만 MCP 도구로 받습니다. 모델의 도구 인수로 실행 파일 경로, 셸 명령이나 스크립트 코드를 받지 않습니다. 중계기는 호출 직전에 실행 파일과 스크립트의 해시를 검사합니다. Python은 `-I -S -B -X utf8`로 실행합니다. Node는 `--preserve-symlinks-main`으로 고정 `.cjs` 파일을 실행하며 reparse 경로 거부를 유지합니다. 두 경로 모두 cmd, PowerShell, pip, npm이나 npx를 호출하지 않습니다. + +네이티브 실행기는 매번 고유 AppContainer 프로필을 생성합니다. 해당 SID에 준비한 런타임과 스크립트 디렉터리의 읽기 및 실행 권한과 호출별 작업, 홈, 임시 디렉터리의 쓰기 권한을 부여합니다. 프로세스를 일시 중단 상태로 생성한 뒤 `TokenIsAppContainer`를 검사하고 Job Object에 넣은 다음 실행을 재개합니다. 기본 작업 프로세스에는 네트워크 capability를 부여하지 않습니다. Microsoft의 [AppContainer 구현 안내](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer)와 [Job Object 문서](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects)에서 API 동작을 설명합니다. + +다만 일반 AppContainer는 호스트 커널을 공유합니다. 기존 ACL이 `ALL APPLICATION PACKAGES`에 허용한 Windows 리소스는 읽을 수 있습니다. 가짜 비공개 파일의 접근 거부만으로 모든 호스트 경로의 차단을 입증하지는 않습니다. Job Object는 자원과 프로세스 수명을 제한하며 AppContainer 토큰과 ACL은 접근을 통제합니다. 이번 실험은 Windows Sandbox나 VM을 사용하지 않습니다. 시스템 방화벽, 전역 PATH와 전역 Codex 설정도 변경하지 않습니다. + +## 전용 디렉터리에서의 준비와 평가 + +현재 실험은 저장소의 .NET 11 SDK와 Windows x64를 사용합니다. 평가 장비는 빌드 26300을 실행합니다. Windows 10 22H2와 ARM64의 실제 동작 검증은 남아 있습니다. API 지원 버전만으로 해당 운영체제에서 런타임과 제품까지 동작한다고 판정하지 않습니다. [CreateAppContainerProfile 지원 조건](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createappcontainerprofile)을 참고할 수 있습니다. + +다음 명령은 호스트를 빌드하고 검증한 런타임을 준비한 뒤 격리한 작업 프로세스를 실행합니다. `prepare`는 고정 ZIP이 없으면 저장소의 아티팩트 캐시에 내려받고 해시를 검사합니다. 쓰기 범위는 `Artifacts`로 제한합니다. 선택한 공개 API 평가 명령은 로컬 검사도 다시 실행합니다. + +```powershell +$env:DOTNET_GENERATE_ASPNET_CERTIFICATE = 'false' +$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' +dotnet build tools/appcontainer-skill-poc/Poc.csproj -c Release +$poc = 'tools/appcontainer-skill-poc/bin/Release/net11.0-windows10.0.18362.0/TableCloth.AppContainerPoc.dll' +dotnet $poc prepare $PWD.Path +$ownedRoot = Join-Path $PWD.Path 'Artifacts/appcontainer-skill-poc' +dotnet $poc selftest $ownedRoot +dotnet $poc evaluate-public-api $ownedRoot +dotnet $poc codex-probe $ownedRoot +``` + +준비 단계는 [Python 3.14.9 embeddable x64](https://www.python.org/downloads/release/python-3149/)와 [Node.js 24.22.1 Windows x64](https://nodejs.org/dist/v24.22.1/)를 고정합니다. SHA-256 값은 [영문 안내](README.md)에 기록했습니다. 자동 업그레이드는 수행하지 않으며 공식 ZIP의 라이선스 고지도 보존합니다. + +## 스킬 3개의 재활성화와 별도 어댑터 + +평가용 카탈로그에서 `tablecloth-calculators`, `bok-ecos-stats`와 `kosis-stats`를 다시 활성화했습니다. 기존 지침형 스킬 3개도 유지했습니다. 부모 카탈로그 PR과 운영 카탈로그는 이 실험과 분리합니다. [skills/ORIGINS.json](skills/ORIGINS.json)에 원본 커밋과 파일 목록을 기록했습니다. [원본 평가 안내](skills/README.ko.md)는 명령, 인증정보와 라이선스 조건을 설명합니다. + +계산기는 고정 원본 Python 스크립트를 JSON 표준 입력으로 호출합니다. 중계기는 비율에 해당하는 값, 단일 할인, 대출 상환, 날짜 덧셈, 고정 UTC 오프셋 변환과 BMI 숫자 계산을 허용합니다. 세율, 의료 판정이나 실시간 금융 데이터는 제공하지 않습니다. + +BOK와 KOSIS의 원본 지침은 npm 기반 지침 로더를 사용합니다. 이번 실험은 고정 원본의 독립 Python 조회 도우미를 별도 어댑터로 호출합니다. 원래의 `npx @nomadamas/k-skill@0` 경로는 재현하거나 지원 판정하지 않습니다. 별도 Node 예제는 Node 격리 실행을 검증하며 마켓플레이스 스킬로 등록하지 않습니다. + +중계기는 `requiresNetwork` 등록을 모두 차단합니다. KOSIS 중계기 등록은 `--dry-run`을 사용합니다. 별도 공개 API 평가만 호스트가 고정한 BOK 공개 sample 요청 1건과 KOSIS 공용 프록시 요청 1건에 `internetClient`를 부여합니다. 이 권한은 도메인 허용 목록보다 넓으므로 마켓플레이스 도구에 네트워크를 허용했다는 뜻으로 해석하지 않습니다. 사용자 API 키, 인증 파일과 호스트의 인증정보 환경 변수는 작업 프로세스에 전달하지 않습니다. + +## 실제 실행과 입력 검사 증거 + +검사기는 `Artifacts/appcontainer-skill-poc/evaluation.json`에 결과를 기록합니다. 공개 API 평가를 실행하면 `evaluation-public-api.json`도 생성합니다. 각 기록은 검사명, 성공 여부와 크기를 제한한 실행 증거를 포함합니다. 가짜 실행기를 주입하는 중계기 검사는 `broker-offline` 접두사로 구분하며 입력 검증 동작만 입증합니다. + +실제 네이티브 검사는 계산 결과, Node JSON 실행, KOSIS dry-run, AppContainer 토큰, 비공개 가짜 파일의 읽기 및 쓰기 거부, 작업 파일 쓰기, 네트워크 접근 거부, 자식 프로세스 거부와 인증정보 환경 변수 누락을 다룹니다. 메모리, 출력 및 시간 제한, 취소, ACL 복원과 실제 표준 입출력 MCP 연결도 검사합니다. 네트워크 시간 초과만으로는 차단 성공을 판정하지 않습니다. + +최종 검사 46개가 모두 통과했습니다. 이 중 20개는 가짜 실행기를 사용하는 중계기 검사로 구분합니다. 원본 계산기 테스트 28개와 BOK 테스트 19개도 AppContainer 안에서 통과했습니다. 기존 Managed AI 회귀 검사는 197개를 통과했습니다. 각 수치는 검증 단계를 나타내며 네이티브 보안 검사 수로 합산하지 않습니다. + +`codex-probe`는 셸 도구를 끈 Codex 0.156.1에서 MCP, AppContainer와 고정 계산기를 거치는 실제 연결에 성공했습니다. 로컬 결정적 Responses 모의 서버가 도구 호출을 전달하며 실제 모델 추론과 인증은 수행하지 않습니다. 새 프로필은 등록한 계산기 도구 하나만 명시적으로 승인합니다. 서버의 기본 승인은 `prompt`로 두며 전역 승인 정책 `never`와 제품 프로필을 유지합니다. + +[평가 보고서](RESULTS.ko.md)에 최종 측정값과 실패를 기록합니다. 원본 코드와 출처는 [원본 평가 안내](skills/README.ko.md)에서 추적할 수 있습니다. 실제 서비스 호출이 실패하면 가짜 응답을 성공 결과로 대체하지 않습니다. + +## 제품 연결과 후속 검증 범위 + +현재 PoC는 식탁보 채팅 창이나 설치기에 연결하지 않습니다. 로컬 실행 등록 파일은 호스트가 신뢰하는 설정으로 취급하며 원격 카탈로그 XML만으로 실행 스크립트를 등록하지 않습니다. 제품 통합 범위에는 실행 등록 규격, 의존 파일 무결성, 업데이트 및 제거 정책, 비정상 종료 복구, 중계기 배포, 사용자 실행 동의와 대상 운영체제 검증이 남아 있습니다. + +PoC는 권한 변경 전에 원래 DACL과 무결성 레이블을 기록합니다. 소유 파일의 배타적 잠금으로 여러 프로세스의 ACL 변경을 직렬화하며 복구 기록이 남아 있으면 실행을 차단합니다. `recover-acl `는 소유 파일의 식별자를 검사하고 권한을 복원한 뒤 기록을 지웁니다. 진단 과정에서 복구를 실제로 실행했으며 강제 전원 차단 후 복구는 검증하지 않았습니다. Windows가 Low 레이블 제거 후 빈 SACL 제어 플래그를 남길 수 있어 DACL과 실제 무결성 레이블 ACE를 비교합니다. + +Codex의 [MCP 설정](https://learn.chatgpt.com/docs/extend/mcp)은 도구 전송 경로를 제공합니다. MCP 자체는 코드를 격리하지 않습니다. 셸 도구를 끈 상태에서 고정 CLI가 등록 도구를 호출하는지와 실제 ChatGPT 추론, 인증 및 제품의 엄격한 프로필 정책을 구분해 기록합니다. + +여기까지 정리하면 이번 AppContainer PoC는 등록한 스크립트에 가벼운 프로세스 격리 경로를 적용합니다. 실제 로컬 실행 결과로 측정한 범위 안에서 구현 가능성을 평가합니다. + +운영 마켓플레이스의 등재 기준은 기존 브랜치 정책을 따릅니다. 네트워크를 사용하는 스킬과 일반 npm 실행 경로는 더 넓은 접근 범위를 다루는 정책과 검증을 후속 작업으로 남깁니다. diff --git a/tools/appcontainer-skill-poc/README.md b/tools/appcontainer-skill-poc/README.md new file mode 100644 index 00000000..246d5a6c --- /dev/null +++ b/tools/appcontainer-skill-poc/README.md @@ -0,0 +1,71 @@ +# AppContainer skill execution proof of concept + +[한국어](README.ko.md) + +As of October 25, 2026, this branch evaluates fixed Python and Node.js scripts in ordinary Windows AppContainer processes. Production TableCloth chat retains `shell_tool=false` and does not automatically enable this broker or reinstall the removed runtime feature. + +The experiment covers a native worker, a fixed-script MCP broker, pinned skill fixtures and an evaluation-only catalog. It separates offline broker checks, actual contained execution, service access and Codex routing. + +The following sections describe the execution boundary, reproducible commands, skill adaptations, measured results and remaining product work. + +## Host broker and contained workers + +The trusted host exposes registered skill IDs and JSON input through a small stdio MCP server. It never accepts executable paths, shell commands or script text from tool arguments. Registered executable and script hashes are checked before each call. Python starts with `-I -S -B -X utf8`; Node runs a fixed `.cjs` file with `--preserve-symlinks-main`. Reparse paths remain rejected. Neither path calls `cmd`, PowerShell, pip, npm or npx. + +The native runner creates a unique AppContainer profile, grants that SID read and execute access to staged runtime/script directories, and grants write access to per-call work/home/temp directories. It creates the process suspended, verifies `TokenIsAppContainer`, assigns a Job Object, and then resumes it. The default worker has no network capability. See Microsoft's [AppContainer implementation](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer) and [Job Object documentation](https://learn.microsoft.com/en-us/windows/win32/procthread/job-objects). + +Ordinary AppContainer shares the host kernel. Windows resources whose ACLs already grant `ALL APPLICATION PACKAGES` can remain readable. Synthetic private-file denial does not establish that every host path is inaccessible. Job Objects bound resources and lifetime; AppContainer tokens and ACLs enforce the access boundary. No Windows Sandbox, VM, firewall changes, global PATH changes or global Codex changes participate. + +## Dedicated preparation and evaluation commands + +The PoC uses the repository's .NET 11 SDK and Windows x64. The current evaluation machine runs build 26300. Windows 10 22H2 and ARM64 remain separate qualification targets. The native APIs predate Windows 10, but API availability does not establish runtime and product compatibility on those targets. See [CreateAppContainerProfile requirements](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createappcontainerprofile). + +The commands below build the host, stage verified archives and run only contained workers. `prepare` downloads missing fixed archives into the repository's artifact cache and rejects a hash mismatch. It writes only under `Artifacts`. The optional public-API command also reruns the local checks. + +```powershell +$env:DOTNET_GENERATE_ASPNET_CERTIFICATE = 'false' +$env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' +dotnet build tools/appcontainer-skill-poc/Poc.csproj -c Release +$poc = 'tools/appcontainer-skill-poc/bin/Release/net11.0-windows10.0.18362.0/TableCloth.AppContainerPoc.dll' +dotnet $poc prepare $PWD.Path +$ownedRoot = Join-Path $PWD.Path 'Artifacts/appcontainer-skill-poc' +dotnet $poc selftest $ownedRoot +dotnet $poc evaluate-public-api $ownedRoot +dotnet $poc codex-probe $ownedRoot +``` + +Preparation pins [Python 3.14.9 embeddable x64](https://www.python.org/downloads/release/python-3149/) to SHA-256 `3c01d7cfc666f746a6f064f60fb2156ab0ec464d3d738ab796371a46a9bb24a2` and [Node.js 24.22.1 Windows x64](https://nodejs.org/dist/v24.22.1/) to `d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95fa5`. These are experiment inputs, not an automatic upgrade policy. The official archives retain their own license notices. + +## Three reactivated skills and explicit adapters + +The evaluation-only catalog reactivates `tablecloth-calculators`, `bok-ecos-stats` and `kosis-stats`. It also retains the three previously eligible instruction skills. The parent catalog PR and production catalog remain separate. Source commits and all fixture files are recorded in [skills/ORIGINS.json](skills/ORIGINS.json); the original files retain their licenses. See the [fixture guide](skills/README.md) for command and credential contracts. + +The calculator runs its exact pinned Python JSON-stdin script. Six operation families enter the broker allowlist: a percentage part, a single discount, loan repayment, date addition, fixed UTC-offset conversion and numeric BMI. No tax rates, medical classification or live financial data are supplied. + +BOK and KOSIS upstream instructions use an npm-based instruction loader. This experiment invokes their pinned standalone Python helpers through explicit adapters. It does not reproduce or qualify the upstream `npx @nomadamas/k-skill@0` workflow. The Node fixture proves contained Node execution independently; it is not an additional marketplace skill. + +The broker blocks every registration marked `requiresNetwork`. KOSIS's broker registration uses `--dry-run`. The separate host-owned public-API evaluation grants `internetClient` for one fixed BOK public-sample request and one fixed KOSIS shared-proxy request. That capability is broader than a domain allowlist. It does not authorize network-enabled marketplace tools. No user API keys, auth files or host credential environment variables enter workers. + +## Evidence from actual execution + +The harness writes `Artifacts/appcontainer-skill-poc/evaluation.json` and, when requested, `evaluation-public-api.json`. Each record contains the check name, outcome and bounded execution evidence. Offline checks inject a fake executor and retain the `broker-offline` prefix. They establish validation behavior only. + +Actual native cases cover calculation results, Node JSON execution, KOSIS dry-run, AppContainer token verification, private canary read/write denial, permitted work writes, network access-denied responses, child-process rejection, secret-environment omission, memory/output/time limits, cancellation, ACL restoration and real stdio MCP dispatch. A network timeout does not count as enforced network denial. + +The final run passed 46/46 harness checks, including 20 fake-executor broker checks. It also ran 28 upstream calculator tests and 19 BOK tests inside AppContainer. The existing Managed AI suite passed 197/197. These counts describe different test layers and are not combined into a native-security test count. + +`codex-probe` passed actual Codex 0.156.1 -> MCP -> AppContainer -> pinned calculator routing with shell tools disabled. A loopback deterministic Responses stub supplied the tool call; no real model inference or authentication occurred. Its fresh profile explicitly approves only the registered calculator tool, keeps the server default at `prompt`, and retains global approval policy `never`. The production profile remains unchanged. + +The [evaluation report](RESULTS.md) records the final measurements and failures. Fixed upstream code and citations remain in the [fixture guide](skills/README.md). No fabricated service response substitutes for a failed live request. + +## Product integration and remaining qualification + +The prototype is not connected to the TableCloth chat window or its installer. Its local manifest is trusted host configuration; a remote marketplace XML entry cannot add a script registration. Production adoption would add a reviewed registration schema, dependency integrity, update/removal rules, qualified crash recovery, broker packaging, user-visible execution consent and target-OS qualification. + +The PoC journals original DACLs and mandatory labels before grants, serializes ACL changes across processes with an owned-file lease, and blocks execution while a recovery journal remains. `recover-acl ` replays owned-file identities and restores those descriptors before clearing a journal. Recovery was exercised during diagnosis; forced power-loss recovery has not been qualified. Restoration compares DACLs and actual mandatory-label ACEs because Windows can retain empty-SACL control flags after removing a Low label. + +Codex's [MCP configuration](https://learn.chatgpt.com/docs/extend/mcp) supplies the tool transport. MCP itself does not isolate code. The fixed CLI's ability to route a registered tool with shell tools disabled is reported separately from real ChatGPT inference, authentication and the production application's strict profile policy. + +In this experiment, AppContainer provides a lightweight process boundary for registered scripts. A successful local worker demonstrates a feasible execution path within the measured limits. + +Production marketplace eligibility continues to follow its existing branch policy. Network-enabled skills and general npm workflows remain separate work until their policy and qualification evidence cover the broader access. diff --git a/tools/appcontainer-skill-poc/RESULTS.ko.md b/tools/appcontainer-skill-poc/RESULTS.ko.md new file mode 100644 index 00000000..3ea44ba1 --- /dev/null +++ b/tools/appcontainer-skill-poc/RESULTS.ko.md @@ -0,0 +1,58 @@ +# AppContainer 스킬 실행 평가 결과 + +[English](RESULTS.md) + +2026년 10월 25일에 Windows x64 환경에서 검사 46개와 실제 Codex 0.156.1의 MCP 연결 시험이 모두 통과했습니다. 호스트는 Windows 빌드 26300을 보고했습니다. 식탁보 제품의 채팅 경로와 셸 실행 정책은 유지했습니다. + +이번 결과는 Python과 Node 격리 실행, 고정 스킬 어댑터, 공개 API 요청, 접근 및 자원 제한과 로컬 결정적 응답을 사용한 도구 연결을 다룹니다. 실제 모델 추론과 제품 통합은 검증하지 않았습니다. + +측정 환경부터 검증 단계, 호환성 수정, 스킬별 결과와 후속 검증 범위를 살펴보겠습니다. [evaluation-summary.json](evaluation-summary.json)에 정제한 증거와 로컬 원본 보고서의 해시를 보존했습니다. + +## 고정 환경과 결과 추적 경로 + +Python 3.14.9 embeddable x64, Node.js 24.22.1 x64, Codex 0.156.1과 SDK 11.0.100-rc.1.26425.128을 사용했습니다. [PoC 안내](README.ko.md)에 런타임 ZIP 해시와 재현 명령을 기록했으며 [ORIGINS.json](skills/ORIGINS.json)에 원본 파일과 라이선스를 연결했습니다. + +로컬 원본 증거는 `Artifacts/appcontainer-skill-poc/evaluation-public-api.json`, `codex-probe-evidence.json`과 `test-results/appcontainer-poc-managed-ai.trx`에 보존했습니다. 커밋한 요약에서는 장비별 경로, 프로필 SID, 인증정보와 서비스 응답 전체를 제외했습니다. 빌드 26300의 API 실행 성공만으로 Windows 10 22H2나 ARM64의 지원을 판정하지 않습니다. Microsoft의 [프로필 API 지원 조건](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createappcontainerprofile)은 API 가용성을 설명합니다. + +## 검증 단계별 통과 범위 + +각 검사 수치는 원래 범위를 유지합니다. 검사기 합계에는 원본 단위 테스트 실행 두 번을 각각 한 항목으로 포함하며 내부 단위 테스트 수를 다시 더하지 않습니다. + +| 검증 단계 | 결과 | 확인한 범위 | +| --- | --- | --- | +| 중계기 오프라인 검사 | 20/20 | 가짜 실행기로 입력, 경로, 해시와 프로토콜 검사 | +| 나머지 검사기 항목 | 26/26 | 실제 작업 프로세스, 정책 거부, 대조 검사, 공개 요청과 정리 | +| 격리한 계산기 원본 테스트 | 28/28 | 원본 산술 및 입력 검증 | +| 격리한 BOK 원본 테스트 | 19/19 | 실제 요청을 제외한 원본 도우미 단위 검사 | +| 기존 Managed AI 회귀 검사 | 197/197 | 카탈로그 파서, 설치와 기존 백엔드 동작 | +| 실제 CLI 연결 시험 | 통과 | 로컬 응답 모의 서버를 사용한 Codex, MCP와 AppContainer 연결 | + +Python과 Node 모두 비공개 가짜 파일의 읽기 및 쓰기를 거부하면서 작업 디렉터리 쓰기를 허용했습니다. 호스트에 주입한 가짜 인증정보 환경 변수도 상속하지 않았습니다. 네트워크 권한이 없는 Python은 Winsock 10013을, Node는 `EACCES`를 반환했습니다. 자식 프로세스 생성은 Python에서 Win32 1816으로 실패했습니다. Node는 프로세스 한도 1개에서 `UNKNOWN/-4094`를 반환했으며 한도를 2개로 바꾼 대조 검사에서는 같은 고정 자식 프로세스를 실행했습니다. 중계기의 한도는 1개를 유지합니다. + +시간 초과, 취소, 출력 크기 제한과 메모리 64 MiB 제한 검사도 통과했습니다. DACL과 실제 무결성 레이블 ACE가 실행 전후 일치했으며 최종 네이티브 호출별 작업 디렉터리와 ACL 복구 기록이 남지 않았습니다. 이번 검사는 구현한 제한 동작을 다루며 일반적인 샌드박스 탈출 감사를 대신하지 않습니다. Microsoft의 [AppContainer 구현 안내](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer)에서 접근 통제 구조를 설명합니다. + +## 최종 실행에서 확인한 호환성 수정 + +실행기는 변경한 DACL과 무결성 레이블을 분리해 복원합니다. 원래 레이블이 없으면 non-null 빈 ACL로 임시 Low 레이블을 제거합니다. Windows가 빈 SACL 제어 플래그를 남길 수 있어 DACL과 실제 무결성 ACE의 바이트를 비교합니다. 소유 파일의 배타적 잠금으로 여러 프로세스의 변경을 직렬화하고 복구 기록이 남으면 새 실행을 차단합니다. 복구 기록 재적용은 실제로 확인했으며 강제 종료와 전원 차단 후 복구는 검증하지 않았습니다. + +Node는 `--preserve-symlinks-main`으로 고정 시작 파일을 엽니다. 경로 정규화 과정의 드라이브 루트 접근을 피하면서 reparse와 하드 링크 거부를 유지합니다. 추가 외부 모듈과 npm 로더는 검증 범위에 포함하지 않았습니다. [Node CLI 옵션](https://nodejs.org/docs/latest-v24.x/api/cli.html#--preserve-symlinks-main)에서 동작을 확인할 수 있습니다. + +Python은 `-I -S -B -X utf8`로 실행합니다. `-I`가 `PYTHONUTF8` 환경 변수를 무시하므로 고정 인수로 UTF-8 모드를 지정해 CP949 출력을 UTF-8로 해석하는 문제를 해결했습니다. 한글 표준 입출력과 최종 BOK 및 KOSIS 데이터 검사가 통과했습니다. [Python 명령행 문서](https://docs.python.org/3.14/using/cmdline.html)에서 격리와 UTF-8 옵션을 설명합니다. + +## 스킬 3개의 실행 결과와 Codex 도구 연결 + +`tablecloth-calculators`는 중계기를 통해 고정 원본 스크립트를 실행했습니다. 할인, 퍼센트, 윤일 덧셈, UTC 오프셋 변환의 날짜 변경, BMI 숫자 계산과 무이자 대출 상환 사례를 통과했습니다. BOK는 원본 Python 도우미로 공개 sample 데이터 한 행을 반환했습니다. KOSIS도 공용 프록시에서 인구 검색 결과 한 행을 반환했습니다. 두 스킬의 원본 npm 지침 로더는 실행하지 않았습니다. [원본 평가 안내](skills/README.ko.md)에서 별도 Python 어댑터를 구분합니다. + +중계기는 네트워크가 필요한 등록을 차단합니다. BOK와 KOSIS의 실제 요청만 호스트가 고정한 평가 경로에서 `internetClient`를 사용합니다. 이 권한은 도메인 허용 목록보다 넓으므로 임의의 네트워크 스킬을 운영 등재할 근거로 사용하지 않습니다. Codex 계산기 연결 시험의 작업 프로세스는 네트워크를 허용하지 않았습니다. + +연결 시험은 새 `CODEX_HOME`과 로컬 Responses 모의 서버를 사용했습니다. Codex는 계산기 도구를 노출하고 namespace를 포함한 호출을 받아 실제 중계기를 실행했습니다. 반환 결과의 `wasAppContainer=true`, `status=completed`와 최종 계산값 `80.00`을 확인했습니다. 셸 도구는 노출하지 않았습니다. 계산기 MCP 도구 하나만 `approve`로 지정했으며 전역 승인 정책은 `never`, 서버 기본 승인은 `prompt`를 유지했습니다. 인증 파일을 복사하거나 생성하지 않았으며 모의 서버에 인증 헤더를 보내지 않았습니다. 실제 모델 추론은 0회입니다. [공식 MCP 설정 안내](https://learn.chatgpt.com/docs/extend/mcp)에서 도구별 설정을 설명합니다. + +## 평가용 카탈로그와 제품 검증 경계 + +카탈로그 평가 분기는 스크립트 스킬 3개를 재활성화하고 기존 3개를 유지해 활성 6개와 주석 9개를 포함합니다. [카탈로그 평가 보고서](../../external/TableClothCatalog/docs/APP_CONTAINER_POC.ko.md)에 평가 예외를 기록했습니다. API 키가 필요한 후보와 연결하지 않은 MCP 후보는 계속 보류합니다. 이번 초안 실험은 부모 PR이나 운영 등재 정책을 변경하지 않습니다. + +제품 통합 범위에는 실행 등록 규격, 의존 파일 전체의 무결성, 중계기와 런타임 배포 및 관리, 동의와 업데이트 동작, 제품 프로필 연결, 도메인 제한 네트워크, 비정상 종료 복구와 Windows 10 및 ARM64 검증이 남아 있습니다. 일반 AppContainer는 기존 ACL이 `ALL APPLICATION PACKAGES`에 허용한 리소스에 접근할 수 있으며 VM 수준의 분리를 주장하지 않습니다. + +여기까지 정리하면 이번 구현은 범용 셸 도구를 끈 상태에서 고정 Python과 Node 스크립트를 가벼운 프로세스 격리 경로로 실행했습니다. + +계산기는 실제 도구 연결까지 검증했습니다. 두 통계 도우미는 고정 공개 서비스 접근도 확인했습니다. 제품 채택과 운영 등재는 이번 로컬 PoC 결과와 구분해 판단할 수 있습니다. diff --git a/tools/appcontainer-skill-poc/RESULTS.md b/tools/appcontainer-skill-poc/RESULTS.md new file mode 100644 index 00000000..9835303a --- /dev/null +++ b/tools/appcontainer-skill-poc/RESULTS.md @@ -0,0 +1,58 @@ +# AppContainer skill execution evaluation results + +[한국어](RESULTS.ko.md) + +On October 25, 2026, the Windows x64 experiment passed all 46 harness checks and an actual Codex 0.156.1 MCP routing probe. The host reported Windows build 26300. Production TableCloth chat and its shell policy remain unchanged. + +The results cover contained Python and Node execution, pinned skill adapters, fixed public API requests, access and resource limits, and tool routing driven by a local deterministic stub. Real model inference and production integration remain untested. + +This report identifies the measured environment, test layers, corrected compatibility issues, skill outcomes and remaining qualification. [evaluation-summary.json](evaluation-summary.json) retains sanitized evidence and hashes of the local raw reports. + +## Fixed environment and evidence sources + +The experiment used Python 3.14.9 embeddable x64, Node.js 24.22.1 x64, Codex 0.156.1 and SDK 11.0.100-rc.1.26425.128. Runtime archive hashes and reproduction commands appear in the [PoC guide](README.md); upstream file identities and licenses appear in [ORIGINS.json](skills/ORIGINS.json). + +Raw local evidence resides in `Artifacts/appcontainer-skill-poc/evaluation-public-api.json`, `codex-probe-evidence.json` and `test-results/appcontainer-poc-managed-ai.trx`. The committed summary excludes machine-specific paths, profile SIDs, credentials and full service response bodies. Successful AppContainer API use on build 26300 does not qualify Windows 10 22H2 or ARM64. Microsoft's [profile API requirements](https://learn.microsoft.com/en-us/windows/win32/api/userenv/nf-userenv-createappcontainerprofile) describe availability separately. + +## Distinct verification layers + +The following counts retain their original scope. Harness totals include the two contained upstream test runs as two checks; their individual unit tests are not added to the harness total. + +| Layer | Result | Established scope | +| --- | --- | --- | +| Offline broker checks | 20/20 | Fake executor, input/path/hash/protocol validation only | +| Other harness checks | 26/26 | Real workers, policy rejection, controls, public requests and cleanup | +| Contained calculator tests | 28/28 | Exact upstream arithmetic and input validation suite | +| Contained BOK tests | 19/19 | Exact upstream helper unit suite, no live requests in that suite | +| Existing Managed AI regression | 197/197 | Catalog parser, installation and existing backend behavior | +| Real CLI routing probe | Passed | Codex -> MCP -> AppContainer -> calculator, local response stub | + +Python and Node denied private canary reads/writes while permitting work-directory writes. Neither inherited a synthetic host secret. Network-disabled Python returned Winsock 10013; Node returned `EACCES`. Python child creation returned Win32 1816. Node returned `UNKNOWN/-4094` under a one-process Job and successfully created the same fixed child under a two-process Job control. The broker always retains a one-process limit. + +Timeout, cancellation, bounded output and a 64 MiB memory allocation probe passed. DACLs and actual mandatory-label ACEs matched before and after execution. Native per-run workspaces and ACL journals were empty after the final probes. These checks exercise the implemented boundary and do not establish a general sandbox escape audit. See Microsoft's [AppContainer implementation](https://learn.microsoft.com/en-us/windows/win32/secauthz/implementing-an-appcontainer). + +## Compatibility fixes exercised by the final run + +The runner restores changed DACLs and mandatory labels separately. Removing a temporary Low label uses a non-null empty ACL when no original label existed. Windows can retain empty-SACL control flags, so restoration compares the DACL and mandatory ACE bytes instead of assuming identical SDDL text. An owned-file lease serializes processes, and a pending ACL journal blocks new launches until recovery succeeds. Actual journal recovery was exercised; forced termination and power-loss recovery remain unqualified. + +Node starts its fixed main file with `--preserve-symlinks-main` to avoid canonicalization that attempted to inspect the drive root. Reparse and hard-link checks still apply. Additional third-party modules and npm loaders are outside the measured scope. See the [Node CLI option](https://nodejs.org/docs/latest-v24.x/api/cli.html#--preserve-symlinks-main). + +Python starts with `-I -S -B -X utf8`. `-I` ignores `PYTHONUTF8`, so the explicit UTF-8 option prevents CP949 output from being decoded as UTF-8 by the host. A Korean stdin/stdout round trip and the final BOK/KOSIS data checks passed. See [Python command-line isolation and UTF-8 options](https://docs.python.org/3.14/using/cmdline.html). + +## Three skill outcomes and the Codex transport + +`tablecloth-calculators` executed the exact pinned script through the broker. Discount, percentage, leap-day addition, UTC-offset date rollover, numeric BMI and zero-rate loan cases passed. BOK returned one public sample row through its exact Python helper. KOSIS returned one population-search row through its shared proxy. Their original npm instruction loaders were not executed; the two helper calls use explicit PoC adapters described in the [fixture guide](skills/README.md). + +The generic broker blocks network-required registrations. BOK and KOSIS live tests use only fixed host-owned requests with `internetClient`, which grants broader access than a domain allowlist. The successful requests do not qualify arbitrary network-enabled marketplace tools. The calculator's successful Codex path uses a network-disabled worker. + +The routing probe used a fresh `CODEX_HOME` and a loopback Responses stub. Codex advertised the calculator, received a namespaced tool call, executed the real broker and received `wasAppContainer=true`, `status=completed` and the calculated final amount `80.00`. Shell tools were absent. Only the exact calculator MCP tool has an `approve` override; global approval remains `never` and the server default remains `prompt`. No auth file was copied or created, no authorization header reached the stub, and real model inference occurred zero times. See the [official MCP configuration](https://learn.chatgpt.com/docs/extend/mcp). + +## Evaluation catalog and product qualification boundary + +The catalog experiment reactivates three script entries and retains three existing entries, for six active entries and nine commented candidates. [The catalog report](../../external/TableClothCatalog/docs/APP_CONTAINER_POC.md) documents the evaluation exception. API-key and unconnected MCP candidates remain deferred. The parent PRs and production catalog policy do not change through this draft experiment. + +Product work still includes a reviewed execution-registration contract, complete dependency integrity, packaged broker/runtime lifecycle, consent and update behavior, production-profile integration, domain-limited network access, crash qualification and Windows 10/ARM64 tests. Ordinary AppContainer retains access to resources already granted to `ALL APPLICATION PACKAGES`; a VM boundary is not claimed. + +The measured implementation supports lightweight contained execution for fixed Python and Node scripts while preserving disabled generic shell tools. + +The calculator has the strongest tested tool path in this experiment. The two statistics helpers additionally demonstrate fixed public-service access. Production adoption remains a separate decision from these local PoC results. diff --git a/tools/appcontainer-skill-poc/evaluation-summary.json b/tools/appcontainer-skill-poc/evaluation-summary.json new file mode 100644 index 00000000..e691297a --- /dev/null +++ b/tools/appcontainer-skill-poc/evaluation-summary.json @@ -0,0 +1,309 @@ +{ + "evaluatedUtc": "2026-10-25T21:19:01.2703595+09:00", + "os": "Microsoft Windows NT 10.0.26300.0", + "architecture": "X64", + "qualification": "PoC only; no production integration, real model inference, Windows 10 22H2 or ARM64 qualification.", + "sourceReportSha256": "5489b04ad12cbce500cfab5e41b904d113a00206e5fccd9829bce835fa48fdfa", + "sourceCodexReportSha256": "4c283d01b2d355407c0af708c8b389b1b9d44b024f82b8e3590aa8c57f06daeb", + "harness": { + "passed": 46, + "total": 46, + "offlineFakeExecutorChecks": 20, + "remainingEvaluationChecks": 26 + }, + "containedUpstreamUnitTests": { + "calculator": 28, + "bok": 19 + }, + "managedAiRegression": { + "passed": 197, + "failed": 0, + "skipped": 0, + "sourceTrxSha256": "c5497010048bb31d7d283bc4cb0ee6349f0b377b2815b99031b2c6f72065d14f" + }, + "pythonIsolation": { + "outside_read": { + "allowed": false, + "winerror": null, + "errno": 13 + }, + "outside_write": { + "allowed": false, + "winerror": null, + "errno": 13 + }, + "network": { + "allowed": false, + "winerror": 10013, + "errno": 13 + }, + "child": { + "allowed": false, + "winerror": 1816 + }, + "inherited_secret": null, + "work_write": true + }, + "nodeIsolation": { + "outside_read": { + "allowed": false, + "code": "EPERM" + }, + "outside_write": { + "allowed": false, + "code": "EPERM" + }, + "network": { + "allowed": false, + "code": "EACCES" + }, + "child": { + "allowed": false, + "code": "UNKNOWN", + "errno": -4094 + }, + "inherited_secret": null, + "work_write": true + }, + "nodeTwoProcessJobPositiveControl": { + "outside_read": { + "allowed": false, + "code": "EPERM" + }, + "outside_write": { + "allowed": false, + "code": "EPERM" + }, + "network": { + "allowed": false, + "code": "EACCES" + }, + "child": { + "allowed": true, + "exit": 0 + }, + "inherited_secret": null, + "work_write": true + }, + "publicApi": { + "bokSampleRows": 1, + "kosisProxyRows": 1, + "kosisQuery": "인구", + "utf8Validated": true, + "userCredentialsUsed": false, + "capability": "internetClient; no domain allowlist; fixed host-owned evaluation only", + "genericBrokerNetworkEnabled": false + }, + "codex": { + "version": "codex-cli 0.156.1", + "success": true, + "shellTool": false, + "unifiedExec": false, + "approvalPolicy": "never", + "approvedMcpTool": "tablecloth_skill_poc/tablecloth-calculators", + "modelInference": false, + "authenticationAttempted": false, + "existingAuthenticationCopied": false, + "credentialHeaderPresent": false, + "shellWasAdvertised": false, + "responseRequests": 2, + "completedMcpEvent": true, + "finalAgentEvent": true, + "actualAppContainerResult": { + "status": "completed", + "exitCode": 0, + "stdout": "{\"ok\":true,\"operation\":\"discount.single\",\"result\":{\"original\":\"100.00\",\"discount\":\"20.00\",\"final\":\"80.00\"},\"assumptions\":[\"Money uses 2 decimal places and ROUND_HALF_UP.\",\"No tax or service-specific rules are applied.\",\"Discounts multiply sequentially; only the final price is rounded. Discount = rounded original - rounded final.\"]}\r\n", + "stderr": "", + "wasAppContainer": true, + "timedOut": false, + "outputLimited": false, + "failure": null + } + }, + "checks": [ + { + "name": "broker-offline.fixed-worker-request-and-cleanup", + "passed": true + }, + { + "name": "broker-offline.tool-input-cannot-add-command-code-or-path", + "passed": true + }, + { + "name": "broker-offline.duplicate-input-properties", + "passed": true + }, + { + "name": "broker-offline.oversized-tool-input", + "passed": true + }, + { + "name": "broker-offline.unknown-skill-never-executes", + "passed": true + }, + { + "name": "broker-offline.network-registration-never-executes", + "passed": true + }, + { + "name": "broker-offline.fixed-cli-has-no-model-arguments", + "passed": true + }, + { + "name": "broker-offline.node-fixture-has-fixed-main-option-and-numeric-input", + "passed": true + }, + { + "name": "broker-offline.changed-script-hash-is-rechecked", + "passed": true + }, + { + "name": "broker-offline.manifest-rejects-outside-script-path", + "passed": true + }, + { + "name": "broker-offline.manifest-rejects-outside-interpreter", + "passed": true + }, + { + "name": "broker-offline.alternate-data-stream-paths-rejected-before-io", + "passed": true + }, + { + "name": "broker-offline.manifest-rejects-incorrect-script-hash", + "passed": true + }, + { + "name": "broker-offline.manifest-rejects-duplicate-properties", + "passed": true + }, + { + "name": "broker-offline.worker-requires-appcontainer-evidence", + "passed": true + }, + { + "name": "broker-offline.worker-output-is-bounded", + "passed": true + }, + { + "name": "broker-offline.stdio-jsonrpc-and-tool-contract", + "passed": true + }, + { + "name": "broker-offline.stdio-oversize-ends-with-protocol-error", + "passed": true + }, + { + "name": "broker-offline.stdio-invalid-utf8-has-no-content-leak", + "passed": true + }, + { + "name": "broker-offline.cancellation-awaits-fake-worker-and-cleans-work", + "passed": true + }, + { + "name": "calculator.discount", + "passed": true + }, + { + "name": "calculator.percent", + "passed": true + }, + { + "name": "calculator.leap-date", + "passed": true + }, + { + "name": "calculator.time-rollover", + "passed": true + }, + { + "name": "calculator.bmi", + "passed": true + }, + { + "name": "calculator.zero-rate-loan", + "passed": true + }, + { + "name": "kosis.fixed-dry-run", + "passed": true + }, + { + "name": "bok.broker-network-denied", + "passed": true + }, + { + "name": "node.registered-script", + "passed": true + }, + { + "name": "tablecloth-calculators.upstream-unit-tests-contained", + "passed": true + }, + { + "name": "bok-ecos-stats.upstream-unit-tests-contained", + "passed": true + }, + { + "name": "broker.no-arbitrary-code", + "passed": true + }, + { + "name": "probe.host-positive-control", + "passed": true + }, + { + "name": "python.isolation", + "passed": true + }, + { + "name": "node.isolation", + "passed": true + }, + { + "name": "python.utf8-roundtrip", + "passed": true + }, + { + "name": "node.child-positive-control-two-process-job", + "passed": true + }, + { + "name": "python.timeout", + "passed": true + }, + { + "name": "python.output-bound", + "passed": true + }, + { + "name": "python.memory-bound", + "passed": true + }, + { + "name": "python.cancellation", + "passed": true + }, + { + "name": "mcp.stdio-real-worker", + "passed": true + }, + { + "name": "native.acl-restored", + "passed": true + }, + { + "name": "native.workspace-cleaned", + "passed": true + }, + { + "name": "bok-ecos-stats.public-api-evaluation", + "passed": true + }, + { + "name": "kosis-stats.public-api-evaluation", + "passed": true + } + ] +} diff --git a/tools/appcontainer-skill-poc/skills/ORIGINS.json b/tools/appcontainer-skill-poc/skills/ORIGINS.json new file mode 100644 index 00000000..fdda0330 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/ORIGINS.json @@ -0,0 +1,253 @@ +{ + "purpose": "Pinned upstream evaluation fixtures only; not production skill qualification.", + "files": [ + { + "localPath": "bok-ecos-stats/LICENSE", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "LICENSE", + "gitBlobSha1": "14fac913ccf80234b1848540089a3bbcb6e5283d", + "bytes": 1056 + }, + { + "localPath": "kosis-stats/LICENSE", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "LICENSE", + "gitBlobSha1": "14fac913ccf80234b1848540089a3bbcb6e5283d", + "bytes": 1056 + }, + { + "localPath": "bok-ecos-stats/SKILL.md", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "bok-ecos-stats/SKILL.md", + "gitBlobSha1": "2901c3094d889fe33b2cc13720df34c680c46282", + "bytes": 1797 + }, + { + "localPath": "bok-ecos-stats/instruction.md", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "bok-ecos-stats/instruction.md", + "gitBlobSha1": "79e6732f9c8167a5cfc1dc8ccf339898d5a19630", + "bytes": 4350 + }, + { + "localPath": "bok-ecos-stats/scripts/bok_ecos.py", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "bok-ecos-stats/scripts/bok_ecos.py", + "gitBlobSha1": "17d905311fed3c45e9d15cf0f6a66370b7a8c0f6", + "bytes": 11947 + }, + { + "localPath": "bok-ecos-stats/skill.json", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "bok-ecos-stats/skill.json", + "gitBlobSha1": "5b186e4ff8e5d3b30efdf59ce7e0b2e9f3563609", + "bytes": 934 + }, + { + "localPath": "bok-ecos-stats/tests/test_bok_ecos.py", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "bok-ecos-stats/tests/test_bok_ecos.py", + "gitBlobSha1": "d604c7617215d0f0f55bd52a26be65d524c02e4c", + "bytes": 9947 + }, + { + "localPath": "kosis-stats/SKILL.md", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/SKILL.md", + "gitBlobSha1": "0baded64556065a17265c16e28433aa6a3fad4b7", + "bytes": 1748 + }, + { + "localPath": "kosis-stats/instruction.md", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/instruction.md", + "gitBlobSha1": "03a0444ddc8079fc34e13c162b244dd77e07f14f", + "bytes": 15829 + }, + { + "localPath": "kosis-stats/references/kosis-openapi-guide.md", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/references/kosis-openapi-guide.md", + "gitBlobSha1": "9ccf827fbebf401ca01dad5371b531db46eec9e0", + "bytes": 8589 + }, + { + "localPath": "kosis-stats/scripts/run_kosis_stats.py", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/scripts/run_kosis_stats.py", + "gitBlobSha1": "470a7acf73af09cf2739d547bfc71685930f8c66", + "bytes": 32991 + }, + { + "localPath": "kosis-stats/skill.json", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/skill.json", + "gitBlobSha1": "c64a425c8e21d9731199b5f182c19e52a2afb3f5", + "bytes": 860 + }, + { + "localPath": "kosis-stats/tests/__init__.py", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/tests/__init__.py", + "gitBlobSha1": "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391", + "bytes": 0 + }, + { + "localPath": "kosis-stats/tests/fixtures/data_response.json", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/tests/fixtures/data_response.json", + "gitBlobSha1": "6f18c79d8a5d078ee0afb11e05ea30ef74ced1c4", + "bytes": 541 + }, + { + "localPath": "kosis-stats/tests/fixtures/meta_response.json", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/tests/fixtures/meta_response.json", + "gitBlobSha1": "15f013c63daa22d204184f90f160ba276fbd39b6", + "bytes": 98 + }, + { + "localPath": "kosis-stats/tests/fixtures/search_response.json", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/tests/fixtures/search_response.json", + "gitBlobSha1": "45015ddc5ff4aaafd1c6853ec3591133849d3732", + "bytes": 517 + }, + { + "localPath": "kosis-stats/tests/test_run_kosis_stats.py", + "repository": "https://github.com/NomaDamas/k-skill", + "ref": "0102e5133865f00886e37ac8cf39a49e2c7a3d39", + "sourcePath": "kosis-stats/tests/test_run_kosis_stats.py", + "gitBlobSha1": "c5f024b726cca4a48918858cc216ff4dfe1f8a43", + "bytes": 24783 + }, + { + "localPath": "tablecloth-calculators/.github/workflows/test.yml", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": ".github/workflows/test.yml", + "gitBlobSha1": "cc941e41b00a4bb45d4c4837412c5faf222a1043", + "bytes": 676 + }, + { + "localPath": "tablecloth-calculators/.gitignore", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": ".gitignore", + "gitBlobSha1": "85f112e3278ef6dab385a998719686b453e0614b", + "bytes": 102 + }, + { + "localPath": "tablecloth-calculators/CONTRIBUTING.ko.md", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "CONTRIBUTING.ko.md", + "gitBlobSha1": "587209bc9dcd234da770a64935d647eb9d60e715", + "bytes": 3721 + }, + { + "localPath": "tablecloth-calculators/CONTRIBUTING.md", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "CONTRIBUTING.md", + "gitBlobSha1": "988a0310210fc5bd381a15b02bcb25776a5ab66e", + "bytes": 3399 + }, + { + "localPath": "tablecloth-calculators/LICENSE-AGPL", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "LICENSE-AGPL", + "gitBlobSha1": "0ad25db4bd1d86c452db3f9602ccdbe172438f52", + "bytes": 34523 + }, + { + "localPath": "tablecloth-calculators/LICENSE-COMMERCIAL", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "LICENSE-COMMERCIAL", + "gitBlobSha1": "5ce945d9042f858e4afddd81eadffcd18ae813b1", + "bytes": 234 + }, + { + "localPath": "tablecloth-calculators/README.ko.md", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "README.ko.md", + "gitBlobSha1": "73ce476226e8edf2289a996051ff3640f4aa4f56", + "bytes": 5495 + }, + { + "localPath": "tablecloth-calculators/README.md", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "README.md", + "gitBlobSha1": "cb69223f909b8007329be7cb8d68fd1542803b75", + "bytes": 4731 + }, + { + "localPath": "tablecloth-calculators/SKILL.md", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "SKILL.md", + "gitBlobSha1": "154938b1f2863cbc2ab1b25c6379d487e4049dae", + "bytes": 2595 + }, + { + "localPath": "tablecloth-calculators/docs/COPYRIGHT.ko.md", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "docs/COPYRIGHT.ko.md", + "gitBlobSha1": "281fb3f47ffb19231d02033e4353777fceec64dc", + "bytes": 4407 + }, + { + "localPath": "tablecloth-calculators/docs/COPYRIGHT.md", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "docs/COPYRIGHT.md", + "gitBlobSha1": "3e022c10a5bef00b632aec4695ed28c1f9f6968a", + "bytes": 3928 + }, + { + "localPath": "tablecloth-calculators/references/operations.md", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "references/operations.md", + "gitBlobSha1": "e44f09af7ed44cbc249cb40c30c36e6441a44fdc", + "bytes": 18333 + }, + { + "localPath": "tablecloth-calculators/scripts/calculate.py", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "scripts/calculate.py", + "gitBlobSha1": "7cbb8ad3ff6ddc0686b4e7a66d253f93ac86a193", + "bytes": 25571 + }, + { + "localPath": "tablecloth-calculators/tests/test_calculate.py", + "repository": "https://github.com/yourtablecloth/tablecloth-calculators", + "ref": "877b58cf0741db21342445f2b3fcb54ad1ada85c", + "sourcePath": "tests/test_calculate.py", + "gitBlobSha1": "a86b5d850d6372caca5bfe9cff2245cde1215ccf", + "bytes": 19784 + } + ] +} diff --git a/tools/appcontainer-skill-poc/skills/README.ko.md b/tools/appcontainer-skill-poc/skills/README.ko.md new file mode 100644 index 00000000..df99bef7 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/README.ko.md @@ -0,0 +1,37 @@ +# 고정 스킬 평가 원본 + +[English](README.md) + +2026년 10월 25일 기준으로 이 파일들은 `poc/appcontainer-skill-evaluation` 실험을 위한 원본을 제공합니다. 운영 식탁보 대화의 스크립트 실행 지원을 입증하지 않습니다. [ORIGINS.json](ORIGINS.json)에 기록한 공개 커밋과 원본 파일의 바이트를 일치시켜 보존했습니다. + +출처, 명령 계약, 네트워크 의존성과 미검증 실행 범위를 설명합니다. 제안한 Python 직접 호출 어댑터와 원본 CLI 절차를 구분합니다. + +원본 기록부터 살펴보겠습니다. 이후 명령 입력, 서비스 접근, 자격 증명, 라이선스와 결과 표기 범위를 정리하겠습니다. + +## 고정 커밋의 원본 보존 + +BOK와 KOSIS 원본은 [NomaDamas/k-skill의 0102e5133865f00886e37ac8cf39a49e2c7a3d39](https://github.com/NomaDamas/k-skill/tree/0102e5133865f00886e37ac8cf39a49e2c7a3d39)에서 가져왔습니다. 계산기는 [877b58cf0741db21342445f2b3fcb54ad1ada85c](https://github.com/yourtablecloth/tablecloth-calculators/tree/877b58cf0741db21342445f2b3fcb54ad1ada85c)를 사용합니다. 출처 기록은 각 원본 경로, 바이트 수와 Git blob SHA-1을 포함합니다. 원본 파일 안에서 어댑터를 수정하지 않습니다. + +## Python 명령의 입력 계약 + +NomaDamas 보조 스크립트 두 개는 Python 3.9 이상과 표준 라이브러리를 사용하며 계산기는 Python 3.11 이상을 사용합니다. 같은 폴더의 별도 모듈을 import하지 않습니다. BOK와 KOSIS는 명령행 인자를 받고 계산기는 stdin에서 JSON 객체 하나를 읽습니다. 상세 계약은 [BOK 지침](bok-ecos-stats/instruction.md), [KOSIS 지침](kosis-stats/instruction.md)과 계산기의 [연산 참고 문서](tablecloth-calculators/references/operations.md)에 보존했습니다. [영문 안내의 인자 예시](README.md#python-command-contracts)는 제안한 어댑터 입력을 나타내며 서비스 호출 성공을 뜻하지 않습니다. + +## npx 로더의 어댑터 경계 + +원본 [BOK SKILL.md](bok-ecos-stats/SKILL.md)와 [KOSIS SKILL.md](kosis-stats/SKILL.md)는 `npx -y @nomadamas/k-skill@0 instruct ...`를 필수 단계로 안내하며 보조 스크립트도 CLI로 실행합니다. 이번 실험에서는 고정한 `instruction.md`를 읽고 독립 Python 파일을 직접 호출하는 경로를 평가할 수 있습니다. 이 방식은 원본 CLI의 실행 환경별 지침 조립을 재현하지 않으므로 변경한 실행 경로로 구분합니다. 원본 지침과 안전 규칙은 함께 유지합니다. npm의 이동 가능한 `@0` 선택자는 Git 고정 커밋과 다르며 임의 패키지 다운로드, 설치 스크립트나 갱신을 허용하는 근거로 사용하지 않습니다. + +## 네트워크와 자격 증명의 평가 제한 + +[BOK 보조 스크립트](bok-ecos-stats/scripts/bok_ecos.py)는 `https://ecos.bok.or.kr/api/`에 직접 연결합니다. 사용자 키가 없으면 공개 `sample` 키를 사용하며 호출당 최대 10행으로 제한합니다. Dry-run 옵션은 없습니다. Sample로 전환하기 전에 사용자 키를 찾으면서 기본 `~/.config/k-skill/secrets.env` 읽기도 시도합니다. 따라서 평가에서는 전용 홈과 비운 자격 증명 환경을 사용하고 `--secrets-path`로 소유한 빈 파일을 지정합니다. + +[KOSIS 보조 스크립트](kosis-stats/scripts/run_kosis_stats.py)의 일반 조회는 `https://k-skill-proxy.nomadamas.org/v1/kosis/...`에 연결하며 프록시 서버가 자체 KOSIS 키를 제공합니다. `--dry-run`은 URL과 인자만 출력하고 HTTP를 호출하지 않습니다. 프록시의 설정, 접근 가능 여부와 원본 응답은 외부 서비스에 의존합니다. 사용자 자격 증명을 요구하는 `--direct`와 `bigdata`는 평가에서 제외합니다. [계산기](tablecloth-calculators/scripts/calculate.py)는 네트워크와 API 키를 사용하지 않습니다. 원본 준비 과정에서는 실제 사용자 키를 읽거나 요구하거나 전달하지 않았습니다. + +## 원본에 계속 적용하는 라이선스 + +BOK와 KOSIS 사본은 같은 커밋의 MIT 라이선스를 [bok-ecos-stats/LICENSE](bok-ecos-stats/LICENSE)와 [kosis-stats/LICENSE](kosis-stats/LICENSE)에 원문 그대로 보존합니다. 원래의 저작권 표기도 계속 적용합니다. 계산기는 [LICENSE-AGPL](tablecloth-calculators/LICENSE-AGPL), [LICENSE-COMMERCIAL](tablecloth-calculators/LICENSE-COMMERCIAL)과 [저작권 준수 기준](tablecloth-calculators/docs/COPYRIGHT.ko.md)을 보존합니다. 식탁보 저장소에 복사해도 원본 라이선스를 대체하지 않습니다. + +## 실제 결과와 남은 평가 + +여기까지 정리하면 원본 준비에서 실행 없이 공개 고정 Git blob과 파일 31개의 일치를 확인했습니다. 이후 [평가](../RESULTS.ko.md)에서 격리 실행과 고정 공개 API 요청을 포함한 검사 46개가 통과했습니다. 실제 Codex, MCP와 AppContainer의 계산기 호출도 로컬 결정적 Responses 모의 서버로 확인했습니다. 실제 모델 추론과 인증은 각각 0회입니다. 설치, 모델의 스킬 발견, 격리 실행, 실제 추론과 제품 통합은 별도 결과로 기록합니다. + +운영 대화의 기존 실행 정책은 유지합니다. 서비스가 막히거나 응답하지 않으면 해당 실제 호출을 실패 또는 미검증으로 기록하며 가공한 데이터를 성공 결과로 대신 제시하지 않습니다. diff --git a/tools/appcontainer-skill-poc/skills/README.md b/tools/appcontainer-skill-poc/skills/README.md new file mode 100644 index 00000000..ae36cc95 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/README.md @@ -0,0 +1,47 @@ +# Pinned skill evaluation fixtures + +[한국어](README.ko.md) + +As of October 25, 2026, these files support the `poc/appcontainer-skill-evaluation` experiment. They do not establish script support in production TableCloth chat. The upstream files remain byte-identical to the public commits recorded in [ORIGINS.json](ORIGINS.json). + +This guide covers provenance, command contracts, network dependencies and the unverified execution boundary. It separates a proposed direct-Python adapter from the upstream CLI workflow. + +The source records come first, followed by command inputs, service access, credentials, licenses and result reporting. + +## Fixed upstream sources + +The BOK and KOSIS fixtures come from [NomaDamas/k-skill at 0102e5133865f00886e37ac8cf39a49e2c7a3d39](https://github.com/NomaDamas/k-skill/tree/0102e5133865f00886e37ac8cf39a49e2c7a3d39). The calculator comes from [877b58cf0741db21342445f2b3fcb54ad1ada85c](https://github.com/yourtablecloth/tablecloth-calculators/tree/877b58cf0741db21342445f2b3fcb54ad1ada85c). The origin manifest records each source path, byte count and Git blob SHA-1. No upstream file is adapted in place. + +## Python command contracts + +Both NomaDamas helpers use Python 3.9+ and the standard library; the calculator requires Python 3.11+. They import no sibling helper module. BOK and KOSIS accept command-line arguments, while the calculator reads one JSON object from stdin. Their full contracts remain in [BOK instruction.md](bok-ecos-stats/instruction.md), [KOSIS instruction.md](kosis-stats/instruction.md) and the calculator's [operations reference](tablecloth-calculators/references/operations.md). + +The following argument forms describe proposed adapter inputs. They are not unrestricted execution instructions or evidence that a service call succeeded. + +```text + -I -S -B -X utf8 /scripts/bok_ecos.py key --limit 1 --secrets-path + -I -S -B -X utf8 /scripts/bok_ecos.py search --alias 기준금리 --start 20260101 --end 20260102 --secrets-path + -I -S -B -X utf8 /scripts/run_kosis_stats.py search --query 인구 --result-count 1 --json --dry-run + -I -S -B -X utf8 /scripts/calculate.py +stdin: {"operation":"discount.single","price":"100","rate_percent":"20"} +``` + +## Loader adaptation and package boundaries + +The original [BOK SKILL.md](bok-ecos-stats/SKILL.md) and [KOSIS SKILL.md](kosis-stats/SKILL.md) require `npx -y @nomadamas/k-skill@0 instruct ...` and describe helper execution through that CLI. Reading the pinned `instruction.md` and invoking the pinned standalone Python file can avoid that loader in this experiment. This changes the execution path and does not reproduce the CLI's runtime-specific instruction assembly. The experiment retains the original instructions and safety rules. The moving npm `@0` selector is not the fixed Git commit and does not authorize arbitrary package downloads, lifecycle scripts or updates. + +## Network and credential limits + +The [BOK helper](bok-ecos-stats/scripts/bok_ecos.py) directly calls `https://ecos.bok.or.kr/api/` and falls back to the public `sample` key, capped at ten rows per call. It has no dry-run option. It resolves a user key before falling back, including a default `~/.config/k-skill/secrets.env` read attempt. An evaluation therefore uses an isolated owned home, a cleared credential environment and an explicit owned empty `--secrets-path`. + +The [KOSIS helper](kosis-stats/scripts/run_kosis_stats.py) sends ordinary lookups to `https://k-skill-proxy.nomadamas.org/v1/kosis/...`; that service supplies its own KOSIS key. Its `--dry-run` only renders the URL and parameters and performs no HTTP call. Proxy configuration, reachability and upstream response remain external dependencies. `--direct` and `bigdata` require user credentials and remain outside this experiment. The [calculator](tablecloth-calculators/scripts/calculate.py) uses no network or API key. No real user key is read, requested or supplied by this fixture preparation. + +## Upstream licenses + +The BOK and KOSIS copies retain the exact same-commit MIT license in [bok-ecos-stats/LICENSE](bok-ecos-stats/LICENSE) and [kosis-stats/LICENSE](kosis-stats/LICENSE). Their original copyright notices continue to apply. The calculator retains [LICENSE-AGPL](tablecloth-calculators/LICENSE-AGPL), [LICENSE-COMMERCIAL](tablecloth-calculators/LICENSE-COMMERCIAL) and its [copyright policy](tablecloth-calculators/docs/COPYRIGHT.md). Copying these files into the TableCloth repository does not replace those upstream licenses. + +## Result reporting boundary + +Fixture preparation verified all 31 files against the public pinned Git blobs without running them. The subsequent [evaluation](../RESULTS.md) passed 46 harness checks, including contained execution and fixed public API requests. A real Codex/MCP/AppContainer calculator call passed using a deterministic local Responses stub, with real model inference and authentication each at zero. Installation, model discovery, contained execution, real inference and production integration retain separate result categories. + +Production chat keeps its existing execution policy. A blocked or unavailable service remains a failed or unverified live case, and fabricated data never substitutes for it. diff --git a/tools/appcontainer-skill-poc/skills/bok-ecos-stats/LICENSE b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/LICENSE new file mode 100644 index 00000000..14fac913 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/appcontainer-skill-poc/skills/bok-ecos-stats/SKILL.md b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/SKILL.md new file mode 100644 index 00000000..2901c309 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/SKILL.md @@ -0,0 +1,41 @@ +--- +name: bok-ecos-stats +description: 한국은행 ECOS Open API로 기준금리, 환율, 소비자물가지수, 통화량 등 중앙은행 경제통계 시계열과 100대 핵심지표를 조회한다. Use when the user asks 기준금리, 환율 추이, 물가지수, M2, 국고채 금리, 거시경제 통계. Not for 주식 시세(korean-stock-search) or KOSIS 일반 통계(kosis-stats). +license: MIT +metadata: + category: data + locale: ko-KR + phase: v1 +--- + +# bok-ecos-stats + + + +## Get the full instructions (required first step) + +Run this and follow its output as the primary instructions for this skill: + +```bash +npx -y @nomadamas/k-skill@0 instruct bok-ecos-stats +``` + +The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by: + +```bash +npx -y @nomadamas/k-skill@0 files bok-ecos-stats +``` + +Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with: + +```bash +npx -y @nomadamas/k-skill@0 update +``` + +If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/bok-ecos-stats/instruction.md. + +## Hard rules even without the CLI + +- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand. +- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments. +- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries. diff --git a/tools/appcontainer-skill-poc/skills/bok-ecos-stats/instruction.md b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/instruction.md new file mode 100644 index 00000000..79e6732f --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/instruction.md @@ -0,0 +1,95 @@ +# 한국은행 ECOS 경제통계 조회 + +## What this skill does + +한국은행 경제통계시스템(ECOS) Open API `https://ecos.bok.or.kr/api/` 로 중앙은행 경제통계를 조회한다. + +- `search` — 통계코드(또는 alias) 기반 시계열 데이터 조회 +- `tables` — 통계표 카탈로그 목록 +- `items` — 특정 통계표의 항목(item) 목록 +- `key` — 100대 핵심지표 (환율/금리/물가/국민소득 등 최신값) +- `word` — 통계 용어 사전 검색 + +**조회 전용**이다. 투자 판단·전망 단정은 범위 밖이다. + +## When to use + +- "지금 한국은행 기준금리 몇 %야?" +- "최근 원달러 환율 추이 보여줘" +- "소비자물가지수 월별로 뽑아줘" +- "M2 통화량 통계 찾아줘" +- "ECOS에서 국고채 3년 금리 조회해줘" + +## When not to use + +- 주식/증권 시세 → `korean-stock-search`, `toss-investment` +- KOSIS 일반 통계(인구/가구/고용 등) → `kosis-stats` +- 환전 수수료·실시간 매매 환율 비교 (ECOS는 공식 통계 기준) + +## Prerequisites + +- Python 3.9+ (stdlib only, 외부 패키지 없음) +- 사용자 API 키 **불필요** — ECOS 공개 데모 키(`sample`)로 무가입 동작 확인 (2026-07-21). 단 **호출당 최대 10행** 제한이 있다. 공개 엔드포인트이므로 k-skill-proxy를 경유하지 않고 직접 호출한다. + +선택 환경변수 (더 많은 행이 필요할 때): + +- `KSKILL_BOK_ECOS_API_KEY` — https://ecos.bok.or.kr/api 회원가입 후 무료 발급. `~/.config/k-skill/secrets.env` 의 같은 키도 읽는다. + +## Workflow + +### 1. 자주 쓰는 지표는 alias로 바로 조회 + +```bash +npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- search --alias 기준금리 --start 20260101 --end 20260721 --text +npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- search --alias 원달러환율 --start 20260701 --end 20260721 --text +npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- search --alias 소비자물가지수 --start 202501 --end 202606 --text +``` + +지원 alias: `기준금리`, `원달러환율`, `소비자물가지수`(=`cpi`), `M2`(=`통화량`), `국고채3년`. + +`--start`/`--end`는 주기 형식을 따른다: 일(D) `YYYYMMDD`, 월(M) `YYYYMM`, 분기(Q) `YYYYQn`, 연(A) `YYYY`. + +### 2. 최신 핵심지표 한 번에 보기 + +```bash +npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- key --limit 10 --text +``` + +### 3. 임의 통계표 탐색 → 항목 확인 → 시계열 조회 + +```bash +npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- tables --text +npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- items --stat-code 722Y001 --text +npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- search --stat-code 722Y001 --cycle D \ + --start 20260101 --end 20260721 --item-code 0101000 --text +``` + +### 4. 용어가 낯설면 사전 검색 + +```bash +npx -y @nomadamas/k-skill@0 exec bok-ecos-stats scripts/bok_ecos.py -- word --query 소비자물가지수 --text +``` + +`--text` 없이 실행하면 구조화 JSON(`result`, `rows`, `source`)을 출력한다. + +## Data source + +- `https://ecos.bok.or.kr/api///json/kr///` — 쿼리스트링 없는 positional URL. 한글 검색어는 helper가 percent-encoding 처리한다. +- 데모 키 `sample`: 무가입, 호출당 최대 10행 (초과 시 `ERROR-301`). +- 잘못된 키: HTTP 200 + `{"RESULT":{"CODE":"INFO-100"}}`. +- 빈 결과: `{"RESULT":{"CODE":"INFO-200","MESSAGE":"해당하는 데이터가 없습니다."}}` → `result: "empty"`. + +## Failure modes + +| 상황 | 동작 | +| --- | --- | +| 빈 결과 (`INFO-200`) | `result: "empty"` — 기간/코드 확인 안내 | +| 인증키 오류 (`INFO-100`) | 개인 키 발급/확인 안내 | +| sample 10행 초과 (`ERROR-301`) | 개인 키 발급 안내 (helper는 sample 사용 시 limit을 10으로 자동 캡) | +| 알 수 없는 alias, stat-code 없는 search | upstream 미호출, 사용법 오류 출력 | +| HTTP 오류/타임아웃/JSON 파싱 실패 | 차단·점검 가능성 안내 후 종료 코드 1 | + +## Notes + +- 시계열 값은 공식 통계 원천 그대로 전달하고, 해석·전망은 덧붙이지 않는다. +- 통계 기준 시점(`time`)을 반드시 함께 표시한다. diff --git a/tools/appcontainer-skill-poc/skills/bok-ecos-stats/scripts/bok_ecos.py b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/scripts/bok_ecos.py new file mode 100644 index 00000000..17d90531 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/scripts/bok_ecos.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Read-only Bank of Korea ECOS economic statistics helper (stdlib only). + +Site-dependent access path (discovered live 2026-07-21): + + GET https://ecos.bok.or.kr/api///json/kr/// + +- Positional URL segments, no query string. +- The published demo key ``sample`` works without registration but caps each + call at 10 rows (exceeding it returns ERROR-301). +- Invalid keys return HTTP 200 with {"RESULT": {"CODE": "INFO-100", ...}}. +- Empty results return {"RESULT": {"CODE": "INFO-200", "MESSAGE": "해당하는 데이터가 없습니다."}}. + +Services used: +- StatisticSearch////[/] — time series cells +- StatisticTableList — table catalog +- StatisticItemList/ — items of one table +- KeyStatisticList — 100+ headline indicators (환율/금리/물가 등) +- StatisticWord/ — glossary + +Because the demo key works without registration, this skill calls upstream +directly (no k-skill-proxy route). Users may set KSKILL_BOK_ECOS_API_KEY for +larger row limits. +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from typing import Any, Dict, List, Optional + +ECOS_BASE_URL = "https://ecos.bok.or.kr/api" +ECOS_DEMO_KEY = "sample" +SAMPLE_MAX_ROWS = 10 +DEFAULT_SECRETS_PATH = pathlib.Path("~/.config/k-skill/secrets.env").expanduser() +USER_AGENT = "k-skill-bok-ecos/0.1 (+https://github.com/NomaDamas/k-skill)" + +# 자주 쓰는 지표 alias → (stat_code, cycle, item_code) +ALIASES = { + "기준금리": ("722Y001", "D", "0101000"), + "원달러환율": ("731Y001", "D", "0000001"), + "원/달러환율": ("731Y001", "D", "0000001"), + "소비자물가지수": ("901Y009", "M", "0"), + "cpi": ("901Y009", "M", "0"), + "m2": ("101Y004", "M", "BBHA00"), + "통화량": ("101Y004", "M", "BBHA00"), + "국고채3년": ("817Y002", "D", "010200000"), +} + +SEGMENT_PATTERN = re.compile(r"^[A-Za-z0-9_.\-]+$") + +ERROR_HINTS = { + "INFO-100": "인증키가 유효하지 않습니다. KSKILL_BOK_ECOS_API_KEY를 확인하거나 https://ecos.bok.or.kr/api 에서 키를 발급받으세요.", + "ERROR-301": "sample 키는 최대 10건까지만 조회할 수 있습니다. 더 많은 행이 필요하면 개인 키를 발급받아 KSKILL_BOK_ECOS_API_KEY로 지정하세요.", +} + +EMPTY_CODES = {"INFO-200"} + + +class HelperError(RuntimeError): + pass + + +def load_secrets(path: pathlib.Path) -> Dict[str, str]: + values: Dict[str, str] = {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except OSError: + return values + for raw in lines: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def resolve_api_key(secrets_path: str) -> Optional[str]: + env_value = os.environ.get("KSKILL_BOK_ECOS_API_KEY") + if env_value and env_value.strip(): + return env_value.strip() + secrets = load_secrets(pathlib.Path(secrets_path).expanduser()) + value = secrets.get("KSKILL_BOK_ECOS_API_KEY") + return value.strip() if value and value.strip() else None + + +def _check_segment(value: str, label: str) -> str: + if not SEGMENT_PATTERN.match(value or ""): + raise HelperError(f"올바른 {label} 값이 아닙니다: {value!r}") + return value + + +def _resolve_series(args: argparse.Namespace) -> tuple[str, str, Optional[str]]: + if args.alias: + needle = args.alias.strip().lower() + if needle not in ALIASES: + known = ", ".join(sorted(ALIASES)) + raise HelperError(f"알 수 없는 alias 입니다: {args.alias!r} (지원: {known})") + stat_code, cycle, item_code = ALIASES[needle] + return stat_code, cycle, item_code + if not args.stat_code: + raise HelperError("--stat-code 또는 --alias 중 하나가 필요합니다.") + if not args.cycle: + raise HelperError("--stat-code 사용 시 --cycle(A/S/Q/M/SM/D)이 필요합니다.") + return ( + _check_segment(args.stat_code, "stat-code"), + _check_segment(args.cycle, "cycle"), + _check_segment(args.item_code, "item-code") if args.item_code else None, + ) + + +def build_url(args: argparse.Namespace, api_key: Optional[str]) -> str: + key = api_key or ECOS_DEMO_KEY + limit = max(1, args.limit) + if key == ECOS_DEMO_KEY: + limit = min(limit, SAMPLE_MAX_ROWS) + + if args.command == "search": + stat_code, cycle, item_code = _resolve_series(args) + start = _check_segment(args.start, "start") + end = _check_segment(args.end, "end") + segments = [ECOS_BASE_URL, "StatisticSearch", key, "json", "kr", "1", str(limit), stat_code, cycle, start, end] + if item_code: + segments.append(item_code) + return "/".join(segments) + if args.command == "tables": + return "/".join([ECOS_BASE_URL, "StatisticTableList", key, "json", "kr", "1", str(limit)]) + "/" + if args.command == "items": + stat_code = _check_segment(args.stat_code, "stat-code") + return "/".join([ECOS_BASE_URL, "StatisticItemList", key, "json", "kr", "1", str(limit), stat_code]) + if args.command == "key": + return "/".join([ECOS_BASE_URL, "KeyStatisticList", key, "json", "kr", "1", str(limit)]) + "/" + # word + query = (args.query or "").strip() + if not query: + raise HelperError("--query 검색어가 필요합니다.") + encoded = urllib.parse.quote(query, safe="") + return "/".join([ECOS_BASE_URL, "StatisticWord", key, "json", "kr", "1", str(limit), encoded]) + + +def http_get_json(url: str, timeout: int) -> Dict[str, Any]: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + body = response.read().decode("utf-8", "replace") + except urllib.error.HTTPError as exc: + raise HelperError(f"ECOS HTTP 오류: {exc.code}") from exc + except urllib.error.URLError as exc: + raise HelperError(f"ECOS 접속 실패: {exc.reason}") from exc + try: + return json.loads(body) + except json.JSONDecodeError as exc: + raise HelperError("ECOS 응답이 JSON이 아닙니다 (차단 또는 점검 가능성).") from exc + + +def normalize_payload(payload: Dict[str, Any], service: str) -> List[Dict[str, Any]]: + if not isinstance(payload, dict): + raise HelperError("ECOS 응답 형식이 올바르지 않습니다.") + result = payload.get("RESULT") + if isinstance(result, dict): + code = str(result.get("CODE") or "") + if code in EMPTY_CODES: + return [] + message = ERROR_HINTS.get(code) or f"ECOS 오류 [{code}]: {result.get('MESSAGE', '')}" + raise HelperError(message) + body = payload.get(service) + if not isinstance(body, dict) or not isinstance(body.get("row"), list): + raise HelperError("ECOS 응답에서 데이터 행을 찾지 못했습니다 (서비스명/파라미터 확인).") + return [row for row in body["row"] if isinstance(row, dict)] + + +def _project(rows: List[Dict[str, Any]], mapping: Dict[str, str]) -> List[Dict[str, Any]]: + return [{out: row.get(src) for out, src in mapping.items()} for row in rows] + + +PROJECTIONS = { + "search": { + "stat_code": "STAT_CODE", + "stat_name": "STAT_NAME", + "item_name": "ITEM_NAME1", + "unit": "UNIT_NAME", + "time": "TIME", + "value": "DATA_VALUE", + }, + "tables": { + "stat_code": "STAT_CODE", + "stat_name": "STAT_NAME", + "cycle": "CYCLE", + "searchable": "SRCH_YN", + "org": "ORG_NAME", + }, + "items": { + "stat_code": "STAT_CODE", + "item_code": "ITEM_CODE", + "item_name": "ITEM_NAME", + "cycle": "CYCLE", + "start_time": "START_TIME", + "end_time": "END_TIME", + }, + "key": { + "class_name": "CLASS_NAME", + "name": "KEYSTAT_NAME", + "value": "DATA_VALUE", + "cycle": "CYCLE", + "unit": "UNIT_NAME", + }, + "word": { + "word": "WORD", + "content": "CONTENT", + }, +} + +SERVICES = { + "search": "StatisticSearch", + "tables": "StatisticTableList", + "items": "StatisticItemList", + "key": "KeyStatisticList", + "word": "StatisticWord", +} + + +def render_text(command: str, rows: List[Dict[str, Any]]) -> str: + if not rows: + return "조건에 맞는 데이터가 없습니다." + lines = [] + for row in rows: + if command == "search": + lines.append(f"{row['time']} · {row['item_name']}: {row['value']} {row['unit'] or ''}".rstrip()) + elif command == "key": + lines.append(f"[{row['class_name']}] {row['name']}: {row['value']} {row['unit'] or ''} ({row['cycle']})") + elif command == "tables": + lines.append(f"{row['stat_code']} · {row['stat_name']} (주기 {row['cycle'] or '-'})") + elif command == "items": + lines.append(f"{row['item_code']} · {row['item_name']} ({row['cycle']}, {row['start_time']}~{row['end_time']})") + else: + lines.append(f"{row['word']}: {row['content']}") + return "\n".join(lines) + + +def parse_args(argv: List[str]) -> argparse.Namespace: + common = argparse.ArgumentParser(add_help=False) + common.add_argument("--secrets-path", default=str(DEFAULT_SECRETS_PATH)) + common.add_argument("--timeout", type=int, default=30) + common.add_argument("--limit", type=int, default=10, help="조회 행 수 (sample 키는 최대 10)") + common.add_argument("--text", action="store_true", help="사람용 요약 출력") + + parser = argparse.ArgumentParser(description="한국은행 ECOS 경제통계 조회") + subparsers = parser.add_subparsers(dest="command", required=True) + + search = subparsers.add_parser("search", parents=[common], help="통계코드/alias 기반 시계열 조회") + search.add_argument("--stat-code", help="통계표 코드 (예: 722Y001)") + search.add_argument("--cycle", help="주기: A/S/Q/M/SM/D") + search.add_argument("--item-code", help="통계항목 코드 (선택)") + search.add_argument("--alias", help="자주 쓰는 지표 별칭 (기준금리/원달러환율/소비자물가지수/M2/국고채3년)") + search.add_argument("--start", required=True, help="시작 시점 (주기 형식에 맞춤, 예: 20260101, 202601, 2026)") + search.add_argument("--end", required=True, help="종료 시점") + + subparsers.add_parser("tables", parents=[common], help="통계표 목록") + + items = subparsers.add_parser("items", parents=[common], help="통계표의 항목 목록") + items.add_argument("--stat-code", required=True) + + subparsers.add_parser("key", parents=[common], help="100대 핵심 지표 (환율/금리/물가 등)") + + word = subparsers.add_parser("word", parents=[common], help="통계 용어 사전 검색") + word.add_argument("--query", required=True) + + return parser.parse_args(argv) + + +def run(argv: List[str]) -> int: + args = parse_args(argv) + try: + api_key = resolve_api_key(args.secrets_path) + url = build_url(args, api_key) + payload = http_get_json(url, args.timeout) + raw_rows = normalize_payload(payload, SERVICES[args.command]) + rows = _project(raw_rows, PROJECTIONS[args.command]) + if args.text: + print(render_text(args.command, rows)) + else: + print(json.dumps({ + "result": "ok" if rows else "empty", + "rows": rows, + "source": "ecos.bok.or.kr Open API", + }, ensure_ascii=False, indent=2)) + return 0 + except HelperError as exc: + print(str(exc), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(run(sys.argv[1:])) diff --git a/tools/appcontainer-skill-poc/skills/bok-ecos-stats/skill.json b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/skill.json new file mode 100644 index 00000000..5b186e4f --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/skill.json @@ -0,0 +1,9 @@ +{ + "name": "bok-ecos-stats", + "description": "한국은행 ECOS Open API로 기준금리, 환율, 소비자물가지수, 통화량 등 중앙은행 경제통계 시계열과 100대 핵심지표를 조회한다. Use when the user asks 기준금리, 환율 추이, 물가지수, M2, 국고채 금리, 거시경제 통계. Not for 주식 시세(korean-stock-search) or KOSIS 일반 통계(kosis-stats).", + "profiles": [ + "proxy", + "lookup" + ], + "frontmatter": "name: bok-ecos-stats\ndescription: 한국은행 ECOS Open API로 기준금리, 환율, 소비자물가지수, 통화량 등 중앙은행 경제통계 시계열과 100대 핵심지표를 조회한다. Use when the user asks 기준금리, 환율 추이, 물가지수, M2, 국고채 금리, 거시경제 통계. Not for 주식 시세(korean-stock-search) or KOSIS 일반 통계(kosis-stats).\nlicense: MIT\nmetadata:\n category: data\n locale: ko-KR\n phase: v1" +} diff --git a/tools/appcontainer-skill-poc/skills/bok-ecos-stats/tests/test_bok_ecos.py b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/tests/test_bok_ecos.py new file mode 100644 index 00000000..d604c761 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/bok-ecos-stats/tests/test_bok_ecos.py @@ -0,0 +1,258 @@ +import contextlib +import importlib.util +import io +import json +import pathlib +import unittest +import urllib.parse +from unittest import mock + +ROOT = pathlib.Path(__file__).resolve().parents[2] +MODULE_PATH = ROOT / "bok-ecos-stats" / "scripts" / "bok_ecos.py" +SPEC = importlib.util.spec_from_file_location("bok_ecos", MODULE_PATH) +bok_ecos = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(bok_ecos) + + +SEARCH_PAYLOAD = { + "StatisticSearch": { + "list_total_count": 2, + "row": [ + { + "STAT_CODE": "722Y001", + "STAT_NAME": "1.3.1. 한국은행 기준금리 및 여수신금리", + "ITEM_CODE1": "0101000", + "ITEM_NAME1": "한국은행 기준금리", + "UNIT_NAME": "연%", + "TIME": "20260105", + "DATA_VALUE": "2.5", + }, + { + "STAT_CODE": "722Y001", + "STAT_NAME": "1.3.1. 한국은행 기준금리 및 여수신금리", + "ITEM_CODE1": "0101000", + "ITEM_NAME1": "한국은행 기준금리", + "UNIT_NAME": "연%", + "TIME": "20260106", + "DATA_VALUE": "2.5", + }, + ], + } +} + +KEYSTAT_PAYLOAD = { + "KeyStatisticList": { + "list_total_count": 101, + "row": [ + { + "CLASS_NAME": "환율", + "KEYSTAT_NAME": "원/달러 환율(종가)", + "DATA_VALUE": "1473.4", + "CYCLE": "20260721", + "UNIT_NAME": "원", + } + ], + } +} + +TABLES_PAYLOAD = { + "StatisticTableList": { + "list_total_count": 1, + "row": [ + { + "P_STAT_CODE": "0000000001", + "STAT_CODE": "722Y001", + "STAT_NAME": "1.3.1. 한국은행 기준금리 및 여수신금리", + "CYCLE": "D", + "SRCH_YN": "Y", + "ORG_NAME": "한국은행", + } + ], + } +} + +ITEMS_PAYLOAD = { + "StatisticItemList": { + "list_total_count": 1, + "row": [ + { + "STAT_CODE": "722Y001", + "GRP_NAME": "계정항목", + "ITEM_CODE": "0101000", + "ITEM_NAME": "한국은행 기준금리", + "CYCLE": "D", + "START_TIME": "19990506", + "END_TIME": "20260721", + } + ], + } +} + +WORD_PAYLOAD = { + "StatisticWord": { + "list_total_count": 1, + "row": [{"WORD": "소비자물가지수", "CONTENT": "물가 변동을 종합적으로 파악하기 위한 지수."}], + } +} + +BAD_KEY_PAYLOAD = { + "RESULT": {"CODE": "INFO-100", "MESSAGE": "인증키가 유효하지 않습니다. 인증키를 확인하십시오!"} +} + +EMPTY_PAYLOAD = {"RESULT": {"CODE": "INFO-200", "MESSAGE": "해당하는 데이터가 없습니다."}} + +SAMPLE_LIMIT_PAYLOAD = { + "RESULT": {"CODE": "ERROR-301", "MESSAGE": "sample은 최대 10건 이내에서 호출이 가능합니다."} +} + + +class UrlBuildTests(unittest.TestCase): + def test_search_url_uses_sample_key_and_positional_segments(self): + args = bok_ecos.parse_args([ + "search", "--stat-code", "722Y001", "--cycle", "D", + "--start", "20260101", "--end", "20260110", "--item-code", "0101000", + ]) + url = bok_ecos.build_url(args, api_key=None) + self.assertIn("/StatisticSearch/sample/json/kr/1/10/722Y001/D/20260101/20260110/0101000", url) + + def test_search_url_uses_user_key_and_limit(self): + args = bok_ecos.parse_args([ + "search", "--stat-code", "722Y001", "--cycle", "D", + "--start", "20260101", "--end", "20260110", "--limit", "500", + ]) + url = bok_ecos.build_url(args, api_key="MYKEY") + self.assertIn("/StatisticSearch/MYKEY/json/kr/1/500/722Y001/D/20260101/20260110", url) + + def test_sample_key_caps_limit_to_ten(self): + args = bok_ecos.parse_args([ + "search", "--stat-code", "722Y001", "--cycle", "D", + "--start", "20260101", "--end", "20260110", "--limit", "500", + ]) + url = bok_ecos.build_url(args, api_key=None) + self.assertIn("/kr/1/10/", url) + + def test_alias_resolves_to_stat_code_cycle_item(self): + args = bok_ecos.parse_args(["search", "--alias", "기준금리", "--start", "20260101", "--end", "20260110"]) + url = bok_ecos.build_url(args, api_key=None) + self.assertIn("722Y001/D/20260101/20260110/0101000", url) + + def test_unknown_alias_raises(self): + args = bok_ecos.parse_args(["search", "--alias", "없는지표", "--start", "2026", "--end", "2026"]) + with self.assertRaisesRegex(bok_ecos.HelperError, "alias"): + bok_ecos.build_url(args, api_key=None) + + def test_search_requires_stat_code_or_alias(self): + args = bok_ecos.parse_args(["search", "--start", "2026", "--end", "2026"]) + with self.assertRaisesRegex(bok_ecos.HelperError, "stat-code"): + bok_ecos.build_url(args, api_key=None) + + def test_word_url_percent_encodes_korean(self): + args = bok_ecos.parse_args(["word", "--query", "소비자물가지수"]) + url = bok_ecos.build_url(args, api_key=None) + self.assertNotIn("소비자물가지수", url) + self.assertIn(urllib.parse.quote("소비자물가지수"), url) + + def test_invalid_segment_characters_rejected(self): + args = bok_ecos.parse_args([ + "search", "--stat-code", "722Y001/evil", "--cycle", "D", + "--start", "20260101", "--end", "20260110", + ]) + with self.assertRaisesRegex(bok_ecos.HelperError, "stat-code"): + bok_ecos.build_url(args, api_key=None) + + +class NormalizeTests(unittest.TestCase): + def test_normalize_search_rows(self): + rows = bok_ecos.normalize_payload(SEARCH_PAYLOAD, "StatisticSearch") + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]["DATA_VALUE"], "2.5") + + def test_normalize_raises_typed_error_on_bad_key(self): + with self.assertRaises(bok_ecos.HelperError) as ctx: + bok_ecos.normalize_payload(BAD_KEY_PAYLOAD, "StatisticSearch") + self.assertIn("인증키", str(ctx.exception)) + + def test_normalize_returns_empty_list_for_no_data(self): + rows = bok_ecos.normalize_payload(EMPTY_PAYLOAD, "StatisticSearch") + self.assertEqual(rows, []) + + def test_normalize_raises_on_sample_limit_error(self): + with self.assertRaises(bok_ecos.HelperError) as ctx: + bok_ecos.normalize_payload(SAMPLE_LIMIT_PAYLOAD, "StatisticSearch") + self.assertIn("10건", str(ctx.exception)) + + def test_normalize_raises_on_unexpected_shape(self): + with self.assertRaises(bok_ecos.HelperError): + bok_ecos.normalize_payload({"weird": True}, "StatisticSearch") + + +class RunTests(unittest.TestCase): + def _run(self, argv, payload): + stdout = io.StringIO() + with mock.patch.object(bok_ecos, "http_get_json", return_value=payload), \ + contextlib.redirect_stdout(stdout): + code = bok_ecos.run(argv) + return code, stdout.getvalue() + + def test_run_search_outputs_series_json(self): + code, out = self._run( + ["search", "--alias", "기준금리", "--start", "20260101", "--end", "20260110"], + SEARCH_PAYLOAD, + ) + self.assertEqual(code, 0) + payload = json.loads(out) + self.assertEqual(payload["result"], "ok") + self.assertEqual(len(payload["rows"]), 2) + self.assertEqual(payload["rows"][0]["value"], "2.5") + self.assertIn("ecos.bok.or.kr", payload["source"]) + + def test_run_search_empty_is_explicit(self): + code, out = self._run( + ["search", "--stat-code", "722Y001", "--cycle", "D", "--start", "19000101", "--end", "19000102"], + EMPTY_PAYLOAD, + ) + self.assertEqual(code, 0) + payload = json.loads(out) + self.assertEqual(payload["result"], "empty") + self.assertEqual(payload["rows"], []) + + def test_run_key_outputs_headline_stats(self): + code, out = self._run(["key"], KEYSTAT_PAYLOAD) + self.assertEqual(code, 0) + payload = json.loads(out) + self.assertEqual(payload["rows"][0]["name"], "원/달러 환율(종가)") + + def test_run_tables_and_items_and_word(self): + code, out = self._run(["tables"], TABLES_PAYLOAD) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out)["rows"][0]["stat_code"], "722Y001") + + code, out = self._run(["items", "--stat-code", "722Y001"], ITEMS_PAYLOAD) + self.assertEqual(code, 0) + self.assertEqual(json.loads(out)["rows"][0]["item_code"], "0101000") + + code, out = self._run(["word", "--query", "소비자물가지수"], WORD_PAYLOAD) + self.assertEqual(code, 0) + self.assertIn("지수", json.loads(out)["rows"][0]["content"]) + + def test_run_reports_key_error_to_stderr(self): + stderr = io.StringIO() + with mock.patch.object(bok_ecos, "http_get_json", return_value=BAD_KEY_PAYLOAD), \ + contextlib.redirect_stderr(stderr): + code = bok_ecos.run(["search", "--alias", "기준금리", "--start", "2026", "--end", "2026"]) + self.assertEqual(code, 1) + self.assertIn("인증키", stderr.getvalue()) + + def test_run_text_mode_renders_series(self): + stdout = io.StringIO() + with mock.patch.object(bok_ecos, "http_get_json", return_value=SEARCH_PAYLOAD), \ + contextlib.redirect_stdout(stdout): + code = bok_ecos.run(["search", "--alias", "기준금리", "--start", "20260101", "--end", "20260110", "--text"]) + self.assertEqual(code, 0) + self.assertIn("한국은행 기준금리", stdout.getvalue()) + self.assertIn("2.5", stdout.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/LICENSE b/tools/appcontainer-skill-poc/skills/kosis-stats/LICENSE new file mode 100644 index 00000000..14fac913 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/SKILL.md b/tools/appcontainer-skill-poc/skills/kosis-stats/SKILL.md new file mode 100644 index 00000000..0baded64 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/SKILL.md @@ -0,0 +1,41 @@ +--- +name: kosis-stats +description: 국가데이터처가 운영하는 KOSIS(국가통계포털, kosis.kr) Open API로 한국 공식 통계표를 검색하고 메타데이터·데이터·대용량 자료를 조회한다. Use when the user asks for 한국 공식 통계 (인구, 가구, 물가, 고용 등) 수치 조회, not for analysis or visualization. +license: MIT +metadata: + category: data + locale: ko-KR + phase: v1 +--- + +# kosis-stats + + + +## Get the full instructions (required first step) + +Run this and follow its output as the primary instructions for this skill: + +```bash +npx -y @nomadamas/k-skill@0 instruct kosis-stats +``` + +The CLI detects the current runtime (Dolshoi vault/CloakBrowser vs generic) and prints only the applicable instructions, always up to date. Helper files bundled with the CLI are listed by: + +```bash +npx -y @nomadamas/k-skill@0 files kosis-stats +``` + +Keep the CLI and every coding-agent skill install current (including Vercel Agent Skills) with: + +```bash +npx -y @nomadamas/k-skill@0 update +``` + +If `npx` is unavailable, install Node.js 18+ or follow https://github.com/NomaDamas/k-skill#readme, or read the source instructions at https://github.com/NomaDamas/k-skill/blob/main/kosis-stats/instruction.md. + +## Hard rules even without the CLI + +- Never execute payment, message/email delivery, final submission, cancellation, or public posting without the user's explicit approval immediately beforehand. +- Never ask for, print, or store plaintext credentials in chat, files, or shell arguments. +- Never bypass legal, physical-presence, CAPTCHA, identity-proofing, or electronic-signature boundaries. diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/instruction.md b/tools/appcontainer-skill-poc/skills/kosis-stats/instruction.md new file mode 100644 index 00000000..03a0444d --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/instruction.md @@ -0,0 +1,240 @@ +# KOSIS Stats + +## What this skill does + +국가데이터처(구 통계청)가 운영하는 KOSIS(국가통계포털) Open API `https://kosis.kr/openapi/` 로 한국 공식 통계 자료를 조회 자동화한다. + +이 스킬은 **조회 전용**이다. 통계 작성, 데이터 변경, 대시보드 등록, 사용자별 통계 자료 등록은 범위에 포함하지 않는다. + +지원 endpoint: + +- `statisticsSearch.do` — 키워드로 통계표 검색 +- `statisticsData.do?method=getMeta` — 통계표 메타데이터 (분류·항목·단위) +- `statisticsParameterData.do` — 통계표 데이터 셀 조회 (기간/분류 필터) +- `statisticsBigData.do` — 대용량 자료 (사전 등록한 `userStatsId` 필요) +- `statisticsList.do` — 통계목록 카테고리 트리 탐색 (주제별/기관별/국제통계/북한통계 등) +- `statisticsExplData.do` — 통계설명 (조사목적·주기·대상·법적근거·공표방법 등 27개 항목) +- `pkNumberService.do` — 통계주요지표 (1,473개 핵심 지표의 개념·산정방법·출처) + +## When to use + +- "1인 가구 비율 통계 찾아줘" +- "KOSIS에서 고령인구 비율 시도별 데이터 가져와" +- "DT_1IN0001 표 메타데이터 보여줘" +- "최근 5년치 소비자물가지수 KOSIS에서 뽑아줘" +- "국내통계 주제별로 어떤 카테고리가 있어?" (`list`) +- "인구총조사 조사목적·조사주기 알려줘" (`explain`) +- "인구밀도 지표 산정방법 보여줘" (`indicator`) + +## When not to use + +- 실시간 시세나 거래소 데이터를 원하는 경우 (KOSIS는 공식 통계용) +- 데이터 시각화·분석·보고서 작성이 주 목적인 경우 (이 스킬은 raw 데이터 조회만) +- 통계 작성·등록·수정이 필요한 경우 +- 대용량 자료를 받기 위해 사용자별 자료(`userStatsId`)를 새로 등록해야 하는 경우 (KOSIS 웹에서 직접 등록) + +## Prerequisites + +- Python 3.9+ (stdlib only, 외부 패키지 없음) +- 일반 `search`/`meta`/`data`/`list`/`explain`/`indicator`: `k-skill-proxy`의 KOSIS route가 있는 hosted/self-host 프록시에 접근 가능할 것 +- `bigdata` 또는 `--direct`: KOSIS Open API 인증키 (무료, https://kosis.kr/openapi/ 에서 회원가입 후 활용신청) + +```bash +npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- --help +``` + +### Bundled helper and reference + +KOSIS helper와 OpenAPI guide reference는 `@nomadamas/k-skill` CLI 패키지에 +함께 번들된다. raw `SKILL.md`만 설치된 환경에서도 별도로 다운로드하거나 +복구하지 않는다. 조립된 instruction의 `k-skill exec`와 `k-skill read` 명령을 +사용한다. + +## Required environment variables + +- 일반 `search`/`meta`/`data`/`list`/`explain`/`indicator`: 없음. 기본 hosted `https://k-skill-proxy.nomadamas.org` 를 사용한다. +- `KSKILL_PROXY_BASE_URL` — self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted proxy를 사용한다. +- `KSKILL_KOSIS_API_KEY` — `bigdata` 또는 `--direct`로 KOSIS를 직접 호출할 때만 필요하다. + +발급 절차와 호출 한도, 에러 코드 등 자세한 내용은 `npx -y @nomadamas/k-skill@0 read kosis-stats references/kosis-openapi-guide.md` 참고. + +### Credential resolution order (`bigdata` 또는 `--direct` 전용) + +1. 돌쇠 credential mode에서는 provisioned `vault-run` capability를 사용하고, 없으면 `request_vault_credential`로 KOSIS API key 입력 UI를 호출한다. +2. 그 밖의 환경에서는 이미 주입된 환경변수 → host vault → `~/.config/k-skill/secrets.env` (`0600`) 순서로 사용한다. +3. generic fallback에서 값이 없으면 호스트의 가장 안전한 입력 표면으로 받아 vault 또는 dotenv에 저장한다. + +일반 조회 helper는 proxy URL만 읽고, KOSIS 인증키는 proxy 서버에서만 주입한다. `bigdata`/`--direct` 호출만 `KSKILL_KOSIS_API_KEY` 환경변수와 위 secrets 파일을 읽는다. +`list`/`explain`/`indicator`는 `search`/`meta`/`data`와 마찬가지로 proxy 경유로 동작한다. + +## Inputs + +서브커맨드: `search`, `meta`, `data`, `bigdata`, `list`, `explain`, `indicator`. + +공통 옵션: + +- `--text`: 사람용 요약 +- `--json`: 구조화 결과 (기본값) +- `--dry-run`: 인증키 없이 요청 URL/파라미터만 출력 +- `--timeout N`: HTTP 타임아웃 초 단위 (기본 30) +- `--proxy-base-url URL`: 기본 hosted proxy 대신 self-host/alternate proxy 사용 +- `--direct`: proxy를 우회하고 `KSKILL_KOSIS_API_KEY` 로 KOSIS 직접 호출 + +서브커맨드별 입력: + +- `search` + - `--query "키워드"` + - `--result-count N` (1-5000, 기본 20) + - `--start-count N` (페이징 시작, 기본 1) +- `meta` + - `--org-id 101` (기본 101=통계청) + - `--table-id DT_1IN0001` + - `--meta-type TBL|ITM|OBJ` (기본 TBL) +- `data` + - `--org-id 101` + - `--table-id DT_1IN0001` + - `--prd-se M|Q|S|Y|F|IR` (수록 주기) + - `--start YYYY[MM|QQ|HH]`, `--end YYYY[MM|QQ|HH]` + - `--itm-id ALL` (항목 ID, 기본 ALL) + - `--obj-l 1=ALL --obj-l 2=00` (분류 필터, 반복 가능) +- `bigdata` + - `--user-stats-id ` + - `--format json|sdmx|csv` (xls는 바이너리라 helper 미지원 — 필요 시 KOSIS 웹에서 직접 다운로드) + - `--prd-se`, `--new-est-prd-cnt` (선택) +- `list` + - `--vw-cd MT_ZTITLE|MT_OTITLE|MT_GTITLE01|MT_GTITLE02|MT_CHOSUN_TITLE|MT_HANKUK_TITLE|MT_STOP_TITLE|MT_RTITLE|MT_BUKHAN|MT_TM1_TITLE|MT_TM2_TITLE|MT_ETITLE` (필수) + - `--parent-id ` (하위 카테고리 탐색, 기본 빈 문자열=최상위) +- `explain` + - `--stat-id <통계조사ID>` (단독) 또는 `--org-id 101 --table-id DT_1IN0001` (조합, 둘 중 하나 필수) + - `--meta-itm All|statsNm|statsKind|...` (기본 All, 한 번에 단일 필드 또는 All) +- `indicator` + - `--jipyo-id 160` (필수, 지표ID) + - `--service 1|2|3` (기본 1: 1=개념, 2=산정방법·출처, 3=전체설명) + - `--page-no N`, `--num-of-rows N` (페이징) + +## Workflow + +### 1. Ensure proxy access is available + +일반 `search`/`meta`/`data` 는 기본 hosted `k-skill-proxy`를 사용하므로 사용자 KOSIS 키가 필요 없다. self-host를 쓰면 `KSKILL_PROXY_BASE_URL`을 설정한다. + +`bigdata` 또는 `--direct`가 필요할 때만 `KSKILL_KOSIS_API_KEY` 를 credential resolution order에 따라 확보한다. 시크릿이 없다는 이유로 다른 통계 사이트나 비공식 경로를 찾지 않는다. + +### 2. Search for candidate tables + +질문을 먼저 한국어 키워드로 좁히고 `search` 로 후보 통계표를 본다. + +```bash +npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- search --query "1인 가구" --text +``` + +출력에서 `[ORG_ID/TBL_ID]`를 골라 다음 단계에 사용한다. + +### 3. Inspect the table meta before fetching data + +데이터를 받기 전에 분류/단위를 확인한다. + +```bash +npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- meta --table-id DT_1JC1501 --text +``` + +**수록주기는 `meta` 로 확정되지 않는다.** `meta --meta-type TBL` 은 표 명칭(국/영문) 위주라 `--prd-se` 에 넣을 주기를 돌려주지 않고, `search` 응답의 `PRD_SE` 는 `--prd-se` 와 코드 체계가 달라(예: `DT_1IN0001` → `PRD_SE=A` 이지만 실제 조회는 `F`) 그대로 쓰면 안 된다. `STRT_PRD_DE`~`END_PRD_DE` 도 수록 범위일 뿐 간격을 알려주지 않는다. 조사주기는 `explain` 으로 확인하거나 §4 의 프로브로 확정한다. + +```bash +npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- explain \ + --org-id 101 --table-id DT_1IN0001 --meta-itm All --text +``` + +### 4. Fetch a small bounded slice first + +`--prd-se`, `--start`, `--end`, `--obj-l` 으로 범위를 좁혀 작은 슬라이스를 먼저 조회한다. + +```bash +npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- data \ + --table-id DT_1JC1501 --prd-se Y --start 2020 --end 2022 \ + --obj-l 1=ALL --json +``` + +**`--prd-se` 를 먼저 확정한다.** 주기를 틀리면 다른 파라미터가 전부 맞아도 KOSIS는 코드 `30`(결과 없음)만 돌려주며, 메시지로는 원인을 알 수 없다. `explain`(§3)으로 조사주기를 확인하거나, 좁은 기간·최소 분류로 `Y` → `F` → `IR` → `M`/`Q`/`S` 순으로 프로브해 데이터가 나오는 코드를 찾은 뒤 본 조회를 한다. 총조사·인구주택총조사처럼 5년 간격인 표는 연 단위로 보여도 `F`(다년)다. + +```bash +for p in Y F IR; do + npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- data \ + --org-id 101 --table-id DT_1IN0001 --prd-se "$p" \ + --start 2010 --end 2010 --itm-id ALL --obj-l 1=00 --obj-l 2=00 --text +done +``` + +표마다 필수 분류 차원 수가 다르다. **default `--obj-l 1=ALL` 만으로는 부족한 표가 많다.** KOSIS가 코드 `20` (필수요청변수값 누락 objL)을 돌려주면, `meta --table-id --meta-type ITM --json` 으로 ITM 안에 들어 있는 `OBJ_ID`(분류 차원)와 코드를 확인한 뒤 `--obj-l 1=<코드> --obj-l 2=<코드>` 형태로 필요한 차원을 모두 지정한다. (많은 표가 OBJ 메타는 비어 있고 분류가 ITM 안에 들어 있음.) + +40,000셀을 초과하면 KOSIS는 에러 코드 `31` 또는 `41` 을 반환한다. 기간을 좁히거나(예: 5년→1년) 분류 필터의 ALL 을 특정 코드로 바꿔(예: `--obj-l 1=11` 서울만) 호출을 분할한다. 그래도 부족하면 사용자별 통계자료(`userStatsId`)를 등록해 `bigdata` 서브커맨드를 사용한다. + +행정구역 코드 관례: `C1` 코드는 보통 시도가 2자리(`11` 서울, `26` 부산 등), 시군구가 5자리다. `data --json` 응답의 `C1` 필드를 확인해 원하는 단위만 후속 처리에서 필터한다. + +### 5. (Optional) Use bigdata for large datasets + +`bigdata` 는 KOSIS 웹에서 미리 등록한 `userStatsId` 가 필요하다. 미등록 상태면 사용자에게 등록 안내만 하고 멈춘다. + +```bash +npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- bigdata \ + --user-stats-id "openapisample/101/DT_1IN1502/2/1/20191106094026_1" \ + --format json --new-est-prd-cnt 5 +``` + +### 6. Cite the source + +응답을 요약할 때는 `org_id`, `tbl_id`, 기간, 단위(`UNIT_NM`), 그리고 endpoint URL을 함께 적는다. + +## Done when + +- 사용자 질문에 대응하는 통계표 ID(`org_id`/`tbl_id`)가 명확하다. +- 메타데이터를 1회 이상 조회해 분류·단위를 확인했고, `--prd-se` 는 실제 데이터가 나오는 코드로 확정했다. +- 작은 슬라이스부터 단계적으로 데이터를 받았다. +- 결과에 출처(table id, 기간, 단위, endpoint)를 명시했다. +- 한도 초과 시 분할 또는 `bigdata` 안내로 처리했다. + +## Failure modes + +- `KSKILL_KOSIS_API_KEY` 누락: `bigdata` 또는 `--direct` 호출에서만 발급 안내 메시지와 함께 종료(exit 1) +- KOSIS 에러 코드 `10`/`11`: 인증키 누락/만료 → 키 점검. `bigdata` 에서 `11` 이 나오면 `userStatsId` 가 본인 KOSIS 계정에 등록된 것이 아닐 가능성이 크다. +- 코드 `20`: 필수 분류 누락 → `meta --meta-type OBJ` (또는 비어 있으면 `ITM`) 으로 필요한 차원 수와 코드를 확인하고 `--obj-l 1=... --obj-l 2=...` 모두 지정 후 재시도 +- 코드 `21`: 잘못된 요청 변수 → `org_id`/`tbl_id`/기간 형식 재확인. tblId 의심 시 `search` 로 정확한 ID 다시 찾기 +- 코드 `30`: 결과 없음. 어느 서브커맨드에서 났는지로 원인이 갈린다. + - **`data` 에서 30 → 먼저 `--prd-se` 오지정을 의심한다.** 표의 실제 수록주기와 다른 코드를 주면 `--start`/`--end`/`--obj-l` 이 전부 맞아도 항상 30 이다. 연 단위처럼 보이는 표라도 총조사·센서스류는 다년주기 `F`(2·3·4·5·10년) 또는 부정기 `IR` 인 경우가 많다. `explain` 으로 조사주기를 확인하거나 `Y` → `F` → `IR` → `M`/`Q`/`S` 순으로 프로브해 확정한다 (§4 참고). + - `search` 에서 30 → 키워드를 더 짧게 또는 다른 표현으로 바꾸거나 기간/분류 완화 + - **`meta` 에서 30** → 표가 해당 메타 타입을 지원하지 않는 경우이므로 다른 `--meta-type` 시도 +- 코드 `31`/`41`: 한도 초과 → 기간 좁히기, 분류 ALL 을 특정 코드로 바꾸기, 또는 `bigdata` 사용 +- 코드 `40`: 분당 1,000건 호출 한도 → 잠시 대기 +- 코드 `50`: KOSIS 서버 오류 → 1~2초 후 재시도 +- 비표준 JSON: KOSIS는 따옴표 없는 키를 가끔 반환한다. helper는 자동 보정한다. +- 응답에 `UNIT_NM` 누락: 일부 표는 KOSIS 응답에 단위가 비어 있다. helper text 출력의 `[summary]` 라인에 `unit=(KOSIS 응답에 UNIT_NM 미포함)` 으로 명시되며, 단위는 `meta` 응답이나 KOSIS 웹 화면에서 별도 확인한다. +- HTTPS 전용 (2026-03-05 이후): URL은 항상 `https://`. HTTP 요청은 차단된다. + +### 회복 시나리오 예시 + +- 코드 20 회복: `data --table-id DT_1J22001 --prd-se M --start 202401 --end 202401` → 코드 20 → `meta --table-id DT_1J22001 --meta-type ITM --json` 으로 차원 확인 → `data ... --obj-l 1=T10 --obj-l 2=0` 재호출 → 성공 +- 코드 31 회복: `data --table-id DT_1B26001 --prd-se Y --start 2020 --end 2024 --obj-l 1=ALL --obj-l 2=ALL --obj-l 3=ALL` → 코드 31 → `... --start 2024 --end 2024 --obj-l 1=11 --obj-l 2=ALL --obj-l 3=ALL` (서울만) 재호출 → 성공 +- 코드 30 회복(수록주기 오지정): `data --org-id 101 --table-id DT_1IN0001 --prd-se Y --start 1990 --end 2010 --itm-id ALL --obj-l 1=00 --obj-l 2=00` → 코드 30 → 연도를 2015·2010·2005·2000·1995·1990 으로 바꿔 재시도해도 전부 코드 30, `--prd-se IR` 도 코드 30 → `--prd-se F --start 1925 --end 2010` 으로 재호출 → 성공(수록 시점 18개, 72셀). `DT_1IN0001`(총조사인구 총괄)은 연 단위 표처럼 보이지만 5년 간격 총조사라 `F` 로만 조회된다. 이 표는 `meta --meta-type TBL` 에도 주기가 없고 `search` 응답 `PRD_SE` 는 `A` 로 나와 둘 다 `--prd-se` 값으로 쓸 수 없었다. + +## Maintainer review notes + +메인테이너가 이 스킬을 검토하기 위해 KOSIS 인증키를 새로 발급받을 필요는 없다. +일반 조회는 `k-skill-proxy`가 KOSIS 인증키를 서버 쪽에서 주입한다. `bigdata` 와 `--direct`만 개인 KOSIS 키가 필요하다. + +키 없이 가능한 검증: + +- `./scripts/validate-skills.sh` +- `python3 -m py_compile kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py` +- `npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- --help` +- `npx -y @nomadamas/k-skill@0 exec kosis-stats scripts/run_kosis_stats.py -- search --query 인구 --dry-run` (URL/파라미터 출력만) +- `PYTHONPATH=kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_*.py' -v` +- `npm run ci` + +실제 direct live smoke는 기여자 또는 이미 KOSIS 키가 있는 사용자가 선택적으로 수행한다. Proxy live smoke는 배포 proxy에 `KOSIS_API_KEY`가 설정된 뒤 수행한다. PR에는 호출 endpoint, 파라미터, 응답 행 수 같은 비민감 요약만 남기고 인증키와 개인 조회 세부 내역은 공유하지 않는다. + +## Safety notes + +- 조회 전용 스킬이다. +- 사용자별 통계자료(`userStatsId`) 등록, 데이터 수정, KOSIS 웹 자동화는 하지 않는다. +- 일반 조회 인증키는 proxy 서버에서만 다룬다. direct/bigdata 인증키는 환경변수 또는 `~/.config/k-skill/secrets.env` 로만 다룬다. +- 응답 JSON에 인증키가 echo 되지 않도록 helper는 `--dry-run` 시에도 키를 `` 으로 대체한다. diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/references/kosis-openapi-guide.md b/tools/appcontainer-skill-poc/skills/kosis-stats/references/kosis-openapi-guide.md new file mode 100644 index 00000000..9ccf827f --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/references/kosis-openapi-guide.md @@ -0,0 +1,175 @@ +# KOSIS Open API 가이드 + +이 문서는 **국가데이터처**(구 통계청)가 운영하는 **KOSIS(국가통계포털, https://kosis.kr)** Open API의 인증키 발급 절차, 호출 한도, 주요 endpoint 사용법, 응답 포맷, 에러 코드를 한국어로 정리한 reference이다. 영문 명칭은 기존 KOSIS / Statistics Korea 그대로 사용된다. + +출처: + +- KOSIS Open API 공식 진입: https://kosis.kr/openapi/ + - 회원가입·활용신청·개발 가이드·내 신청 현황은 사이트 좌측 메뉴에서 진입한다. + - deep-link(`devGuide/...`, `serviceUse/...`)는 SSO/SPA 라우팅에 따라 직접 접근 시 빈 화면이 뜰 수 있으니, 위 진입 URL에서 메뉴를 따라간다. +- KOSIS 공식 공지(2026-03-05 시행, HTTPS 전용·rate limit) — 활용신청 페이지 공지사항에서 확인. +- 본 가이드는 `kosis-mcp` 프로젝트의 `KOSIS_API_REFERENCE.md` 문서를 참고했다. + +--- + +## 1. 인증키 발급 절차 + +KOSIS Open API는 무료다. 발급 단계: + +1. **KOSIS 회원가입** — https://kosis.kr/ 우측 상단 "회원가입" +2. **활용신청** — https://kosis.kr/openapi/serviceUse/serviceUseUnityReg_01Detail.do + - 활용 목적, 호출 빈도, 사용 서비스 종류를 입력한다 + - 신청 후 인증키가 즉시 발급된다 (관리자 승인 불필요) +3. **인증키 확인** — https://kosis.kr/openapi/serviceUse/myMain_01List.do +4. **`KSKILL_KOSIS_API_KEY` 환경변수에 저장** +5. (선택) 활용 사례 등록 — 작성한 어플리케이션 정보를 등록할 수 있다 + +발급된 키는 `apiKey=` 형태로 모든 endpoint 호출에 포함한다. + +--- + +## 2. 호출 한도와 프로토콜 (2026-03-05 적용) + +| 항목 | 제한 | 비고 | +|------|------|------| +| Rate limit | 분당 1,000건 / 키 | 초과 시 에러 코드 `40` | +| 1회 호출당 최대 결과 | 40,000셀 | 초과 시 에러 코드 `31` 또는 `41` | +| 대용량 SDMX | 40,000셀 초과 시 SDMX 불가 → XLS 사용 | `statisticsBigData.do` | +| 대용량 XLS | 200,000셀 초과 시 XLS 불가 → 쿼리 분할 | | +| 프로토콜 | **HTTPS 전용** (HTTP 차단) | 모든 URL은 `https://` | + +40,000셀이란 1회 응답에 포함되는 데이터 셀(수치 값) 수다. 예를 들어 100개 지역 × 10개 항목 × 50년 = 50,000셀 → 1회 호출 불가 → 기간/지역/항목으로 분할. + +--- + +## 3. 주요 Endpoint + +### 3.1 통계 검색 (`statisticsSearch.do`) + +키워드로 통계표를 검색한다. + +``` +GET https://kosis.kr/openapi/statisticsSearch.do + ?method=getList&apiKey={KEY}&format=json&jsonVD=Y + &searchNm=인구&resultCount=20&startCount=1 +``` + +응답 필드(주요): `ORG_ID`, `ORG_NM`, `TBL_ID`, `TBL_NM`, `STAT_ID`, `STAT_NM`, `VW_CD`, `MT_ATITLE`, `PRD_SE`, `STRT_PRD_DE`, `END_PRD_DE`, `LINK_URL`. + +데이터 조회는 `ORG_ID` + `TBL_ID` 조합을 다음 단계에서 사용한다. + +⚠️ **`search` 의 `PRD_SE` 를 §3.3 의 `prdSe` 요청 파라미터로 그대로 쓰지 말 것.** 두 필드는 코드 체계가 다르다 — `DT_1IN0001`(총조사인구 총괄)은 `search` 가 `PRD_SE=A` 를 돌려주지만 실제 데이터 조회는 `prdSe=F` 로만 성공하고 `A`·`Y`·`IR` 은 모두 코드 `30`(결과 없음)이다. `STRT_PRD_DE`~`END_PRD_DE` 도 수록 범위일 뿐 간격(주기)을 알려주지 않는다. 요청용 주기는 `statisticsExplData.do`(helper 의 `explain` 서브커맨드) 의 조사주기 항목에서 확인하거나, §3.3 표의 코드를 좁은 슬라이스로 프로브해 확정한다. + +### 3.2 통계표 메타데이터 (`statisticsData.do?method=getMeta`) + +통계표의 분류·항목·단위·국문/영문명 등을 조회한다. + +``` +GET https://kosis.kr/openapi/statisticsData.do + ?method=getMeta&type=TBL&apiKey={KEY}&format=json + &orgId=101&tblId=DT_1IN0001 +``` + +`type` 값: + +- `TBL` — 통계표 명칭(국/영문) +- `ITM` — 항목(item) 메타 +- `OBJ` — 분류(classifier) 메타 + +### 3.3 통계 데이터 조회 (`statisticsParameterData.do`) + +실제 통계 데이터 셀을 조회한다. 가장 많이 쓰는 endpoint다. + +``` +GET https://kosis.kr/openapi/Param/statisticsParameterData.do + ?method=getList&apiKey={KEY}&format=json&jsonVD=Y + &orgId=101&tblId=DT_1YL20631 + &objL1=ALL&itmId=ALL + &prdSe=Y&startPrdDe=2020&endPrdDe=2024 +``` + +수록주기(`prdSe`)와 기간 형식: + +| 코드 | 설명 | 기간 형식 | +|------|------|----------| +| `M` | 월간/격월 | `YYYYMM` (202401) | +| `Q` | 분기 | `YYYYQQ` (202401 = 1분기) | +| `S` | 반기 | `YYYYHH` (202401 = 상반기) | +| `Y` | 연간 | `YYYY` (2024) | +| `F` | 다년(2,3,4,5,10년) | `YYYY` | +| `IR` | 부정기 | `YYYY` 또는 `YYYYMMDD` | + +`prdSe` 를 틀리면 `startPrdDe`/`endPrdDe`/`objL*` 이 전부 맞아도 응답은 항상 코드 `30`(결과 없음)이고, 메시지만으로는 주기 문제인지 구분되지 않는다. 5년 간격 총조사처럼 연 단위로 보이는 표가 `F`(다년)인 경우가 흔하므로, `statisticsExplData.do` 로 조사주기를 확인하거나 최소 슬라이스로 `Y` → `F` → `IR` → `M`/`Q`/`S` 순으로 프로브해 확정한 뒤 본 조회를 한다. + +분류 파라미터는 `objL1` ~ `objL8` (필요한 만큼만), 항목은 `itmId` (`ALL` 또는 특정 ID). + +응답 셀 필드: `PRD_DE` (기간), `ITM_NM` (항목), `UNIT_NM` (단위), `DT` (값), `C1_NM`~`C8_NM` (분류명). + +### 3.4 대용량 통계자료 (`statisticsBigData.do`) + +40,000셀 초과 데이터를 한 번에 받기 위한 endpoint. **`userStatsId` 사전 등록 필요**. + +``` +GET https://kosis.kr/openapi/statisticsBigData.do + ?method=getList&apiKey={KEY}&format=sdmx + &userStatsId=openapisample/101/DT_1IN1502/2/1/20191106094026_1 + &prdSe=Y&newEstPrdCnt=5 +``` + +`userStatsId` 발급: + +1. KOSIS 로그인 → "개발 가이드 > 대용량 통계자료 > URL생성" +2. 통계표 ID, 항목, 분류, 기간을 선택해 자료 등록 +3. 발급된 `userStatsId` 를 위 URL에 사용 + +응답 형식: `json`, `sdmx` (DSD/Generic/StructureSpecific), `csv`, `xls`. + +> `run_kosis_stats.py bigdata --format` 은 텍스트 응답인 `json`, `sdmx`, `csv` 만 지원한다. `xls` 는 KOSIS가 바이너리 Excel 파일로 응답하므로 helper의 텍스트 출력 경로로 다루지 않는다. xls가 필요하면 KOSIS 웹 화면에서 직접 다운로드하거나, 추후 `--output PATH` 바이너리 모드가 추가되면 그때 사용한다. + +--- + +## 4. 응답 포맷 주의 + +KOSIS API는 가끔 **비표준 JSON**을 반환한다. 키에 따옴표가 없는 경우가 있다. + +```javascript +// 비표준 (KOSIS 원본) +{ORG_ID:"101", TBL_ID:"DT_1YL20631"} + +// 표준 JSON으로 보정 +{"ORG_ID":"101", "TBL_ID":"DT_1YL20631"} +``` + +`run_kosis_stats.py` 의 `fix_unquoted_keys()` 가 자동으로 보정한다. + +`format=json&jsonVD=Y` 조합을 권장한다 (`jsonVD=Y` 는 verbatim 응답). + +--- + +## 5. 에러 코드 + +KOSIS는 에러 시 `{"err": "<코드>", "errMsg": "<메시지>"}` 또는 `{"errCode": "<코드>", "errMsg": "<메시지>"}` 형태로 응답한다. + +| 코드 | KOSIS 메시지 | 카테고리 | 권장 액션 | +|------|---|---|---| +| 10 | 인증키 누락 | auth | URL의 `?apiKey=` 확인 | +| 11 | 인증키 기간만료 | auth | https://kosis.kr/openapi/ 에서 갱신 | +| 20 | 필수요청변수 누락 | input | 필수 파라미터 확인 | +| 21 | 잘못된 요청변수 | input | `orgId`/`tblId`/기간 형식 재확인 | +| 30 | 조회결과 없음 | query | 키워드/기간/분류 완화 | +| 31 | 조회결과 초과 | query | 기간·지역·항목을 분할 | +| 40 | 호출가능건수 제한 | rate_limit | 분당 1,000건 한도 — 잠시 대기 | +| 41 | 호출가능ROW수 제한 | rate_limit | 1회 40,000셀 한도 — 쿼리 분할 | +| 42 | 사용자별 이용 제한 | rate_limit | KOSIS 운영팀 문의 | +| 50 | 서버오류 | server | 1~2초 대기 후 재시도 | + +`run_kosis_stats.py` 는 위 코드를 감지해 사람용 힌트와 함께 stderr로 출력하고 exit 2 로 종료한다. + +--- + +## 6. 안전 사용 가이드 + +- 인증키는 절대 저장소에 커밋하지 않는다. `examples/secrets.env.example` 참고. +- 호출은 분당 1,000건 한도 안에서 한다. 반복 폴링이 필요한 경우 호출 간 sleep을 둔다. +- 대용량 자료를 받을 때는 `userStatsId` 등록부터 사용자에게 안내한다. 자동 등록·웹 자동화는 하지 않는다. +- 응답에 개인식별정보가 포함될 일은 없지만, 비공개 검토 자료에 인증키가 echo되지 않도록 `--dry-run` 출력에서도 키는 `` 으로 대체한다. diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/scripts/run_kosis_stats.py b/tools/appcontainer-skill-poc/skills/kosis-stats/scripts/run_kosis_stats.py new file mode 100644 index 00000000..470a7acf --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/scripts/run_kosis_stats.py @@ -0,0 +1,896 @@ +#!/usr/bin/env python3 +"""Read-only KOSIS (kosis.kr) Open API helper. + +The script wraps four KOSIS endpoints needed to answer everyday Korean +official-statistics questions: + + - statisticsSearch.do : keyword search of statistical tables + - statisticsData.do?getMeta : table metadata (dimensions, units) + - statisticsParameterData.do : actual data cells filtered by classifier + - statisticsBigData.do : large datasets (requires userStatsId) + +It only reads. It never registers user statistics, edits anything, or +performs aggressive polling. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +SEARCH_URL = "https://kosis.kr/openapi/statisticsSearch.do" +META_URL = "https://kosis.kr/openapi/statisticsData.do" +DATA_URL = "https://kosis.kr/openapi/Param/statisticsParameterData.do" +BIGDATA_URL = "https://kosis.kr/openapi/statisticsBigData.do" +LIST_URL = "https://kosis.kr/openapi/statisticsList.do" +EXPLAIN_URL = "https://kosis.kr/openapi/statisticsExplData.do" +INDICATOR_URL = "https://kosis.kr/openapi/pkNumberService.do" +PROXY_BASE_URL_ENV_VAR = "KSKILL_PROXY_BASE_URL" +DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org" + +DEFAULT_TIMEOUT = 30 +PRD_SE_VALUES = {"M", "Q", "S", "Y", "F", "IR"} +# `xls` is intentionally omitted: KOSIS returns it as a binary Excel payload, +# but the helper streams text-only output. Use bigdata json/sdmx/csv (text) +# for now; download xls files manually from the KOSIS web UI if you need them. +BIGDATA_FORMATS = {"json", "sdmx", "csv"} + +# statisticsList.do service view codes +VIEW_CODES = { + "MT_ZTITLE", "MT_OTITLE", "MT_GTITLE01", "MT_GTITLE02", + "MT_CHOSUN_TITLE", "MT_HANKUK_TITLE", "MT_STOP_TITLE", + "MT_RTITLE", "MT_BUKHAN", "MT_TM1_TITLE", "MT_TM2_TITLE", "MT_ETITLE", +} + +# pkNumberService.do sub-services +INDICATOR_SERVICES = {"1", "2", "3"} + +ERROR_CODE_HINTS: dict[str, str] = { + "10": "인증키가 누락되었습니다. KSKILL_KOSIS_API_KEY 환경변수를 확인하세요.", + "11": "인증키가 만료되었거나 해당 endpoint에서 무효입니다. https://kosis.kr/openapi/ 에서 갱신하거나, bigdata는 본인이 등록한 userStatsId 인지 확인하세요.", + "20": "필수 요청 변수가 누락되었습니다. `meta --table-id --meta-type ITM --json` 으로 ITM 안에 들어 있는 OBJ_ID(분류 차원)와 코드를 확인하세요(많은 표가 OBJ 메타는 비어 있고 분류가 ITM 안에 들어 있음). 그 뒤 `--obj-l 1=<코드> --obj-l 2=<코드>` 형태로 필요한 차원을 모두 지정해 재호출하세요. 별도 OBJ 메타가 있는 표는 `--meta-type OBJ` 로도 확인 가능합니다.", + "21": "잘못된 요청 변수입니다. orgId/tblId/기간 형식을 재확인하세요. tblId가 의심되면 `search --query <키워드>` 로 정확한 ID를 다시 찾으세요.", + "30": "조회 결과가 없습니다. data 호출이면 먼저 `--prd-se` 오지정을 의심하세요 — 표의 실제 수록주기와 다르면 기간/분류가 맞아도 항상 이 에러가 납니다. 연 단위로 보이는 총조사·센서스류는 다년주기 `F` 또는 부정기 `IR` 인 경우가 많으니 `explain --org-id --table-id ` 로 조사주기를 확인하거나 좁은 슬라이스로 `Y` → `F` → `IR` → `M`/`Q`/`S` 순으로 프로브하세요(`search` 응답의 `PRD_SE` 는 코드 체계가 달라 그대로 쓸 수 없습니다). search 호출이면 키워드를 더 짧게(예: '1인 가구' → '가구') 또는 다른 표현으로 재검색하거나, 기간/분류 필터를 완화하세요. meta 호출에서 이 에러가 나면 해당 메타 타입을 표가 지원하지 않는 경우이므로 다른 `--meta-type` 을 시도하세요.", + "31": "조회 결과가 한도(40,000셀)를 초과했습니다. 기간을 좁히거나(예: 5년→1년) 분류 필터의 ALL 을 특정 코드로 바꾸세요(예: `--obj-l 1=ALL` → `--obj-l 1=11` 서울만). 그래도 부족하면 `bigdata` 서브커맨드 + 사전 등록한 userStatsId 를 사용하세요.", + "40": "분당 호출 한도(1,000건)를 초과했습니다. 잠시 대기 후 재시도하거나 호출 간 sleep 을 두세요.", + "41": "1회 호출 ROW 한도를 초과했습니다. 기간이나 분류를 좁혀 쿼리를 분할하세요.", + "42": "사용자별 이용이 제한되었습니다. KOSIS 운영팀에 문의하세요.", + "50": "KOSIS 서버 오류입니다. 1~2초 대기 후 재시도하세요.", +} + + +@dataclass +class KosisConfig: + api_key: str + timeout: int = DEFAULT_TIMEOUT + + +class KosisError(RuntimeError): + def __init__(self, code: str | None, message: str) -> None: + self.code = code or "" + super().__init__(message) + + +def parse_resultcount(value: str) -> int: + try: + result = int(value) + except ValueError as exc: + raise argparse.ArgumentTypeError("must be an integer") from exc + if not 1 <= result <= 5000: + raise argparse.ArgumentTypeError("must be between 1 and 5000") + return result + + +def parse_prd_se(value: str) -> str: + upper = value.strip().upper() + if upper not in PRD_SE_VALUES: + raise argparse.ArgumentTypeError( + "must be one of: " + ", ".join(sorted(PRD_SE_VALUES)) + ) + return upper + + +def parse_bigdata_format(value: str) -> str: + lower = value.strip().lower() + if lower not in BIGDATA_FORMATS: + raise argparse.ArgumentTypeError( + "must be one of: " + ", ".join(sorted(BIGDATA_FORMATS)) + ) + return lower + + +def parse_view_code(value: str) -> str: + upper = value.strip().upper() + if upper not in VIEW_CODES: + raise argparse.ArgumentTypeError( + "must be one of: " + ", ".join(sorted(VIEW_CODES)) + ) + return upper + + +def parse_indicator_service(value: str) -> str: + if value not in INDICATOR_SERVICES: + raise argparse.ArgumentTypeError( + "must be one of: " + ", ".join(sorted(INDICATOR_SERVICES)) + ) + return value + + +def _add_common_flags(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--timeout", + type=int, + default=DEFAULT_TIMEOUT, + help=f"HTTP timeout in seconds (default {DEFAULT_TIMEOUT}).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the request URL and parameters without calling KOSIS.", + ) + parser.add_argument( + "--proxy-base-url", + help=( + "k-skill-proxy base URL for search/meta/data " + f"(default {DEFAULT_PROXY_BASE_URL}; override with {PROXY_BASE_URL_ENV_VAR})." + ), + ) + parser.add_argument( + "--direct", + action="store_true", + help="Call KOSIS directly with KSKILL_KOSIS_API_KEY instead of k-skill-proxy.", + ) + output = parser.add_mutually_exclusive_group() + output.add_argument("--json", action="store_true", help="Print JSON output.") + output.add_argument("--text", action="store_true", help="Print human-readable output.") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Read-only KOSIS Open API helper. " + "Place output flags (--json/--text/--dry-run/--timeout) AFTER the subcommand.", + ) + + sub = parser.add_subparsers(dest="command", required=True) + + search = sub.add_parser("search", help="Search statistical tables by keyword.") + _add_common_flags(search) + search.add_argument("--query", required=True, help="Korean keyword (e.g. '1인 가구').") + search.add_argument( + "--result-count", + type=parse_resultcount, + default=20, + help="Result count, 1-5000 (default 20).", + ) + search.add_argument( + "--start-count", + type=int, + default=1, + help="Result offset for pagination (default 1).", + ) + + meta = sub.add_parser("meta", help="Fetch table metadata (dimensions, units).") + _add_common_flags(meta) + meta.add_argument("--org-id", default="101", help="Organization ID (default 101).") + meta.add_argument("--table-id", required=True, help="KOSIS table ID, e.g. DT_1IN0001.") + meta.add_argument( + "--meta-type", + default="TBL", + choices=["TBL", "ITM", "OBJ"], + help="Meta type (default TBL).", + ) + + data = sub.add_parser("data", help="Fetch table data filtered by classifiers.") + _add_common_flags(data) + data.add_argument("--org-id", default="101", help="Organization ID (default 101).") + data.add_argument("--table-id", required=True, help="KOSIS table ID.") + data.add_argument( + "--prd-se", + type=parse_prd_se, + required=True, + help="Period frequency: M Q S Y F IR.", + ) + data.add_argument("--start", required=True, help="Start period (format depends on --prd-se).") + data.add_argument("--end", required=True, help="End period.") + data.add_argument("--itm-id", default="ALL", help="Item ID filter (default ALL).") + data.add_argument( + "--obj-l", + action="append", + default=[], + metavar="N=VALUE", + help="Classifier filter, e.g. --obj-l 1=ALL --obj-l 2=00. Repeatable. " + "If omitted, --obj-l 1=ALL is used.", + ) + + bigdata = sub.add_parser( + "bigdata", + help="Fetch large datasets via statisticsBigData (requires userStatsId).", + ) + _add_common_flags(bigdata) + bigdata.add_argument( + "--user-stats-id", + required=True, + help="userStatsId pre-registered on KOSIS (개발가이드 > 대용량 통계자료 > URL생성).", + ) + bigdata.add_argument( + "--format", + dest="bigdata_format", + type=parse_bigdata_format, + default="json", + help="Output format: json sdmx csv xls (default json).", + ) + bigdata.add_argument( + "--prd-se", + type=parse_prd_se, + help="Period frequency override.", + ) + bigdata.add_argument( + "--new-est-prd-cnt", + type=int, + help="Count of latest periods to fetch (alternative to start/end).", + ) + + list_cmd = sub.add_parser( + "list", + help="Browse statistical table categories (statisticsList.do).", + ) + _add_common_flags(list_cmd) + list_cmd.add_argument( + "--vw-cd", + type=parse_view_code, + required=True, + help="Service view code: MT_ZTITLE(국내통계주제별) MT_OTITLE(기관별) " + "MT_GTITLE01/02(e-지방지표) MT_RTITLE(국제통계) MT_BUKHAN(북한통계) etc.", + ) + list_cmd.add_argument( + "--parent-id", + default="", + help="Parent list ID for sub-categories (sent as parentListId). Empty string = top-level.", + ) + + explain = sub.add_parser( + "explain", + help="Fetch statistical survey description (statisticsExplData.do).", + ) + _add_common_flags(explain) + explain.add_argument( + "--stat-id", + help="Statistics survey ID (alternative to --org-id/--table-id).", + ) + explain.add_argument("--org-id", help="Organization ID (with --table-id).") + explain.add_argument("--table-id", help="KOSIS table ID (with --org-id).") + explain.add_argument( + "--meta-itm", + default="All", + choices=["All", *_EXPLAIN_FIELD_LABELS.keys()] if "_EXPLAIN_FIELD_LABELS" in globals() else None, + help="Requested field: All(전체) or one field name (statsNm, statsKind, writingPurps, ...). Default All.", + ) + + indicator = sub.add_parser( + "indicator", + help="Fetch key indicator metadata (pkNumberService.do).", + ) + _add_common_flags(indicator) + indicator.add_argument( + "--service", + type=parse_indicator_service, + default="1", + help="Sub-service: 1(개념) 2(산정방법·출처) 3(전체설명). Default 1.", + ) + indicator.add_argument( + "--jipyo-id", + required=True, + help="Indicator ID (e.g. 160).", + ) + indicator.add_argument( + "--page-no", + type=int, + default=1, + help="Page number (default 1).", + ) + indicator.add_argument( + "--num-of-rows", + type=int, + default=10, + help="Rows per page (default 10).", + ) + + return parser.parse_args(argv) + + +def load_secrets_env(path: Path) -> dict[str, str]: + if not path.exists(): + return {} + secrets: dict[str, str] = {} + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if "=" not in line: + continue + key, _, value = line.partition("=") + secrets[key.strip()] = value.strip().strip('"').strip("'") + return secrets + + +def resolve_api_key( + *, + env: dict[str, str] | None = None, + secrets_path: Path | None = None, +) -> str: + env_map = env if env is not None else os.environ + direct = env_map.get("KSKILL_KOSIS_API_KEY") + if direct: + return direct.strip() + + candidate = secrets_path or Path("~/.config/k-skill/secrets.env").expanduser() + secrets = load_secrets_env(candidate) + fallback = secrets.get("KSKILL_KOSIS_API_KEY") + if fallback: + return fallback.strip() + + raise SystemExit( + "missing required environment variable: KSKILL_KOSIS_API_KEY\n" + "발급: https://kosis.kr/openapi/ (무료, KOSIS 회원가입 후 활용신청)\n" + "참조: kosis-stats/references/kosis-openapi-guide.md" + ) + + +def resolve_proxy_base_url( + explicit_base_url: str | None = None, + env: dict[str, str] | None = None, +) -> str: + env_map = env if env is not None else os.environ + candidate = (explicit_base_url or env_map.get(PROXY_BASE_URL_ENV_VAR) or "").strip() + if candidate.casefold() in {"off", "false", "0", "disable", "disabled", "none"}: + raise SystemExit(f"{PROXY_BASE_URL_ENV_VAR} is disabled; pass --direct to use KSKILL_KOSIS_API_KEY.") + if candidate and candidate != "replace-me": + return candidate.rstrip("/") + return DEFAULT_PROXY_BASE_URL + + +def parse_obj_l(values: list[str]) -> dict[str, str]: + objs: dict[str, str] = {} + for raw in values: + if "=" not in raw: + raise SystemExit(f"--obj-l must be N=VALUE, got: {raw}") + key, _, value = raw.partition("=") + key = key.strip() + if not key.isdigit() or not 1 <= int(key) <= 8: + raise SystemExit(f"--obj-l index must be 1..8, got: {key}") + objs[f"objL{key}"] = value.strip() or "ALL" + if not objs: + objs["objL1"] = "ALL" + return objs + + +def build_search_params(api_key: str, args: argparse.Namespace) -> dict[str, str]: + return { + "method": "getList", + "apiKey": api_key, + "format": "json", + "jsonVD": "Y", + "searchNm": args.query, + "resultCount": str(args.result_count), + "startCount": str(args.start_count), + } + + +def build_meta_params(api_key: str, args: argparse.Namespace) -> dict[str, str]: + return { + "method": "getMeta", + "type": args.meta_type, + "apiKey": api_key, + "format": "json", + "jsonVD": "Y", + "orgId": args.org_id, + "tblId": args.table_id, + } + + +def build_data_params(api_key: str, args: argparse.Namespace) -> dict[str, str]: + params: dict[str, str] = { + "method": "getList", + "apiKey": api_key, + "format": "json", + "jsonVD": "Y", + "orgId": args.org_id, + "tblId": args.table_id, + "itmId": args.itm_id, + "prdSe": args.prd_se, + "startPrdDe": args.start, + "endPrdDe": args.end, + } + params.update(parse_obj_l(args.obj_l)) + return params + + +def build_bigdata_params(api_key: str, args: argparse.Namespace) -> dict[str, str]: + params: dict[str, str] = { + "method": "getList", + "apiKey": api_key, + "format": args.bigdata_format, + "jsonVD": "Y", + "userStatsId": args.user_stats_id, + } + if args.prd_se: + params["prdSe"] = args.prd_se + if args.new_est_prd_cnt is not None: + params["newEstPrdCnt"] = str(args.new_est_prd_cnt) + return params + + +def build_list_params(api_key: str, args: argparse.Namespace) -> dict[str, str]: + return { + "method": "getList", + "apiKey": api_key, + "format": "json", + "jsonVD": "Y", + "vwCd": args.vw_cd, + "parentListId": args.parent_id, + } + + +def build_explain_params(api_key: str, args: argparse.Namespace) -> dict[str, str]: + params: dict[str, str] = { + "method": "getList", + "apiKey": api_key, + "format": "json", + "jsonVD": "Y", + "metaItm": args.meta_itm, + } + if args.stat_id: + params["statId"] = args.stat_id + elif args.org_id and args.table_id: + params["orgId"] = args.org_id + params["tblId"] = args.table_id + else: + raise SystemExit( + "explain requires either --stat-id or both --org-id and --table-id." + ) + return params + + +def build_indicator_params(api_key: str, args: argparse.Namespace) -> dict[str, str]: + return { + "method": "getList", + "apiKey": api_key, + "format": "json", + "jsonVD": "Y", + "service": args.service, + "serviceDetail": "pkNotion" if args.service == "1" else "pkCalcSource" + if args.service == "2" else "pkAll", + "jipyoId": args.jipyo_id, + "pageNo": str(args.page_no), + "numOfRows": str(args.num_of_rows), + } + + +def build_url(base: str, params: dict[str, str]) -> str: + query = urllib.parse.urlencode(params, quote_via=urllib.parse.quote) + return f"{base}?{query}" + + +def is_proxy_not_configured_body(body: str) -> bool: + try: + payload = json.loads(body) + except json.JSONDecodeError: + return False + return isinstance(payload, dict) and payload.get("error") == "upstream_not_configured" + + +def fetch_text(url: str, timeout: int, *, via_proxy: bool = False) -> str: + request = urllib.request.Request(url, headers={"User-Agent": "k-skill/kosis-stats"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + charset = response.headers.get_content_charset() or "utf-8" + return response.read().decode(charset, errors="replace") + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + if exc.code == 503 and is_proxy_not_configured_body(body): + raise KosisError("503", "k-skill-proxy에 필요한 KOSIS API 키가 설정되어 있지 않습니다. 운영자에게 문의하세요.") from exc + raise KosisError(str(exc.code), f"HTTP {exc.code}: {body[:200]}") from exc + except urllib.error.URLError as exc: + if not via_proxy: + raise KosisError(None, f"network error: {exc.reason}") from exc + raise KosisError( + None, + "설정된 k-skill-proxy 서버가 응답하지 않습니다. " + f"잠시 후 재시도하거나 운영자에게 문의하세요. (상세: {exc.reason})" + ) from exc + + +def fix_unquoted_keys(text: str) -> str: + """KOSIS sometimes returns JSON with unquoted keys.""" + return re.sub(r'([{,])\s*([A-Za-z_][A-Za-z0-9_]*)\s*:', r'\1"\2":', text) + + +def parse_kosis_json(text: str) -> Any: + body = text.strip() + try: + return json.loads(body) + except json.JSONDecodeError: + return json.loads(fix_unquoted_keys(body)) + + +_XML_ERROR_RE = re.compile( + r"\s*([^<]*)\s*([^<]*)", re.IGNORECASE +) + + +def detect_xml_error(text: str) -> KosisError | None: + match = _XML_ERROR_RE.search(text) + if not match: + return None + code = match.group(1).strip() + message = match.group(2).strip() + hint = ERROR_CODE_HINTS.get(code, "") + full = f"KOSIS error {code or '?'}: {message}" + if hint: + full += f" ({hint})" + return KosisError(code, full) + + +def detect_kosis_error(payload: Any) -> KosisError | None: + if isinstance(payload, dict): + message = payload.get("errMsg") + if message: + code = str(payload.get("err") or payload.get("errCode") or "").strip() + hint = ERROR_CODE_HINTS.get(code, "") + full = f"KOSIS error {code or '?'}: {message}" + if hint: + full += f" ({hint})" + return KosisError(code, full) + return None + + +def call_kosis(url: str, timeout: int, *, format_hint: str = "json", via_proxy: bool = False) -> Any: + text = fetch_text(url, timeout, via_proxy=via_proxy) + xml_err = detect_xml_error(text) + if xml_err is not None: + raise xml_err + if format_hint != "json": + stripped = text.lstrip() + if stripped.startswith("{") or stripped.startswith("["): + try: + payload = parse_kosis_json(text) + except json.JSONDecodeError: + return text + err = detect_kosis_error(payload) + if err is not None: + raise err + return text + payload = parse_kosis_json(text) + err = detect_kosis_error(payload) + if err is not None: + raise err + return payload + + +def render_search_text(payload: Any) -> str: + if not isinstance(payload, list) or not payload: + return ( + "조회 결과가 없습니다. 키워드를 더 짧게(예: '1인 가구' → '가구') " + "또는 다른 표현으로 재검색해 보세요. " + "`--result-count` 와 `--start-count` 로 더 많은 후보를 페이징할 수도 있습니다." + ) + lines = [] + for entry in payload: + if not isinstance(entry, dict): + continue + org = entry.get("ORG_NM", "?") + tbl = entry.get("TBL_NM", "?") + org_id = entry.get("ORG_ID", "?") + tbl_id = entry.get("TBL_ID", "?") + prd = f"{entry.get('STRT_PRD_DE', '?')}~{entry.get('END_PRD_DE', '?')}" + lines.append(f"- [{org_id}/{tbl_id}] {org} / {tbl} ({prd})") + lines.append( + "\nNext: `meta --table-id ` 로 분류·항목·단위 확인 → " + "`data --table-id --prd-se --start ... --end ...` 로 작은 슬라이스부터 받기." + ) + return "\n".join(lines) + + +def render_meta_text(payload: Any) -> str: + if not isinstance(payload, list) or not payload: + return ( + "메타 정보가 없습니다. 표가 해당 메타 타입을 지원하지 않을 수 있습니다. " + "다른 `--meta-type` (TBL/ITM/OBJ) 을 시도해 보세요." + ) + lines = [] + for entry in payload: + if not isinstance(entry, dict): + continue + kr = entry.get("TBL_NM") or entry.get("ITM_NM") or entry.get("C_NM") or "?" + en = entry.get("TBL_NM_ENG") or entry.get("ITM_NM_ENG") or entry.get("C_NM_ENG") or "" + suffix = f" / {en}" if en else "" + lines.append(f"- {kr}{suffix}") + return "\n".join(lines) + + +def render_data_text(payload: Any) -> str: + if not isinstance(payload, list) or not payload: + return ( + "데이터가 없습니다. 기간(`--start`/`--end`), 항목(`--itm-id`), " + "분류(`--obj-l`) 필터를 완화하거나 `meta` 로 표 구조를 다시 확인하세요." + ) + lines = [] + units: set[str] = set() + periods: set[str] = set() + for entry in payload[:50]: + if not isinstance(entry, dict): + continue + prd = str(entry.get("PRD_DE", "?")) + unit = str(entry.get("UNIT_NM", "")).strip() + item = entry.get("ITM_NM", "?") + c1 = entry.get("C1_NM", "") + value = entry.get("DT", "?") + suffix = f" ({c1})" if c1 else "" + unit_suffix = f" {unit}" if unit else "" + lines.append(f"- {prd} | {item}{suffix} = {value}{unit_suffix}".rstrip()) + if unit: + units.add(unit) + periods.add(prd) + if len(payload) > 50: + lines.append(f"... ({len(payload) - 50} rows omitted; --json 으로 전체 받기)") + summary_parts = [f"rows={len(payload)}"] + if periods: + period_list = sorted(p for p in periods if p and p != "?") + if period_list: + summary_parts.append( + f"period={period_list[0]}~{period_list[-1]}" + if len(period_list) > 1 else f"period={period_list[0]}" + ) + if units: + summary_parts.append("unit=" + ",".join(sorted(units))) + else: + summary_parts.append("unit=(KOSIS 응답에 UNIT_NM 미포함)") + lines.append("\n[summary] " + ", ".join(summary_parts)) + return "\n".join(lines) + + +_EXPLAIN_FIELD_LABELS: dict[str, str] = { + "statsNm": "조사명", + "statsKind": "작성유형", + "statsEnd": "통계종류", + "statsContinue": "계속여부", + "basisLaw": "법적근거", + "writingPurps": "조사목적", + "examinPd": "조사기간", + "statsPeriod": "조사주기", + "writingSystem": "조사체계", + "writingTel": "연락처", + "statsField": "통계분야·실태", + "examinObjrange": "조사대상범위", + "examinObjArea": "조사대상지역", + "josaUnit": "조사단위·규모", + "applyGroup": "적용분류", + "josaItm": "조사항목", + "pubPeriod": "공표주기", + "pubExtent": "공표범위", + "pubDate": "공표시기", + "publictMth": "공표방법/URL", + "examinTrgetPd": "조사대상기간/기준시점", + "dataUserNote": "자료이용시 유의사항", + "mainTermExpl": "주요 용어해설", + "dataCollectMth": "자료 수집방법", + "examinHistory": "조사연혁", + "confmNo": "승인번호", + "confmDt": "승인일자", +} + + +def render_list_text(payload: Any) -> str: + if not isinstance(payload, list) or not payload: + return ( + "조회 결과가 없습니다. --parent-id 를 비워 최상위 카테고리를 보거나, " + "다른 --vw-cd (서비스뷰 코드)를 시도하세요." + ) + lines: list[str] = [] + for entry in payload: + if not isinstance(entry, dict): + continue + list_id = entry.get("LIST_ID", "") + list_nm = entry.get("LIST_NM", "?") + org_id = entry.get("ORG_ID", "") + tbl_id = entry.get("TBL_ID", "") + tbl_nm = entry.get("TBL_NM", "") + send_de = entry.get("SEND_DE", "") + if tbl_id: + lines.append(f"- [{list_id}] {list_nm} → [{org_id}/{tbl_id}] {tbl_nm} (갱신: {send_de})") + else: + lines.append(f"- [{list_id}] {list_nm}") + lines.append( + "\nNext: 하위 카테고리는 `list --vw-cd <코드> --parent-id `. " + "통계표는 `meta --table-id ` 또는 `data --table-id ...`." + ) + return "\n".join(lines) + + +def render_explain_text(payload: Any) -> str: + if not isinstance(payload, list) or not payload: + return ( + "설명 정보가 없습니다. statId 또는 orgId+tblId 를 재확인하세요. " + "`search --query <키워드>` 로 정확한 ID를 찾을 수 있습니다." + ) + lines: list[str] = [] + for entry in payload: + if not isinstance(entry, dict): + continue + lines.append("---") + for field, label in _EXPLAIN_FIELD_LABELS.items(): + value = entry.get(field, "") + if value: + lines.append(f"{label}: {value}") + if len(lines) == 1 and lines[0] == "---": + return "설명 정보 필드가 모두 비어 있습니다. 다른 --meta-itm 을 시도하세요." + return "\n".join(lines) + + +def render_indicator_text(payload: Any) -> str: + if not isinstance(payload, list) or not payload: + return ( + "지표 정보가 없습니다. --jipyo-id 를 확인하세요. " + "KOSIS 개발가이드에서 사용 가능한 지표ID 목록을 볼 수 있습니다." + ) + lines: list[str] = [] + for entry in payload: + if not isinstance(entry, dict): + continue + jipyo_id = entry.get("statJipyoId", entry.get("jipyoId", "?")) + jipyo_nm = entry.get("statJipyoNm", entry.get("jipyoNm", "?")) + title = entry.get("jipyoExplan", "") + details = [ + ("개념", entry.get("jipyoExplan1", entry.get("jipyoCalc", ""))), + ("산정방법", entry.get("jipyoExplan2", "")), + ("출처", entry.get("jipyoExplan3", "")), + ] + lines.append(f"- [{jipyo_id}] {jipyo_nm}") + if title: + lines.append(f" 제목: {title}") + for label, value in details: + if not value: + continue + display = value[:500] if len(value) > 500 else value + lines.append(f" {label}: {display}") + if len(value) > 500: + lines.append(f" ... ({len(value) - 500}자 생략, --json으로 전체)") + return "\n".join(lines) + + +def render_text(command: str, payload: Any) -> str: + if command == "search": + return render_search_text(payload) + if command == "meta": + return render_meta_text(payload) + if command == "data": + return render_data_text(payload) + if command == "list": + return render_list_text(payload) + if command == "explain": + return render_explain_text(payload) + if command == "indicator": + return render_indicator_text(payload) + return json.dumps(payload, ensure_ascii=False, indent=2) + + +def cite_endpoint(command: str) -> str: + return { + "search": SEARCH_URL, + "meta": META_URL, + "data": DATA_URL, + "bigdata": BIGDATA_URL, + "list": LIST_URL, + "explain": EXPLAIN_URL, + "indicator": INDICATOR_URL, + }[command] + + +def should_use_proxy(args: argparse.Namespace) -> bool: + return args.command in {"search", "meta", "data", "list", "explain", "indicator"} and not args.direct + + +def proxy_endpoint(command: str, base_url: str) -> str: + path = { + "search": "/v1/kosis/search", + "meta": "/v1/kosis/meta", + "data": "/v1/kosis/data", + "list": "/v1/kosis/list", + "explain": "/v1/kosis/explain", + "indicator": "/v1/kosis/indicator", + }[command] + return f"{base_url.rstrip('/')}{path}" + + +def params_without_api_key(params: dict[str, str]) -> dict[str, str]: + return {key: value for key, value in params.items() if key != "apiKey"} + + +def run(args: argparse.Namespace) -> int: + use_json = args.json or not args.text + + use_proxy = should_use_proxy(args) + + if use_proxy or args.dry_run: + api_key = "" if use_proxy else "" + else: + api_key = resolve_api_key() + + builder = { + "search": build_search_params, + "meta": build_meta_params, + "data": build_data_params, + "bigdata": build_bigdata_params, + "list": build_list_params, + "explain": build_explain_params, + "indicator": build_indicator_params, + }[args.command] + base = cite_endpoint(args.command) + params = builder(api_key, args) + if use_proxy: + call_base = proxy_endpoint(args.command, resolve_proxy_base_url(args.proxy_base_url)) + call_params = params_without_api_key(params) + else: + call_base = base + call_params = params + url = build_url(call_base, call_params) + + if args.dry_run: + redacted = dict(call_params) + if not use_proxy and "apiKey" in redacted: + redacted["apiKey"] = "" + if use_json: + print(json.dumps({ + "endpoint": call_base, + "upstream_endpoint": base, + "via_proxy": use_proxy, + "params": redacted, + "url": build_url(call_base, redacted) + }, ensure_ascii=False, indent=2)) + else: + print(f"endpoint: {call_base}") + print(f"upstream_endpoint: {base}") + print(f"via_proxy: {str(use_proxy).lower()}") + print(f"url: {build_url(call_base, redacted)}") + for key, value in redacted.items(): + print(f" {key}={value}") + return 0 + + format_hint = params.get("format", "json") + try: + payload = call_kosis(url, args.timeout, format_hint=format_hint, via_proxy=use_proxy) + except KosisError as exc: + sys.stderr.write(f"{exc}\n") + return 2 + + if use_json: + if isinstance(payload, str): + sys.stdout.write(payload) + if not payload.endswith("\n"): + sys.stdout.write("\n") + else: + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + print(render_text(args.command, payload)) + print(f"\nsource: {base}") + if use_proxy: + print(f"via: {call_base}") + + return 0 + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + return run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/skill.json b/tools/appcontainer-skill-poc/skills/kosis-stats/skill.json new file mode 100644 index 00000000..c64a425c --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/skill.json @@ -0,0 +1,9 @@ +{ + "name": "kosis-stats", + "description": "국가데이터처가 운영하는 KOSIS(국가통계포털, kosis.kr) Open API로 한국 공식 통계표를 검색하고 메타데이터·데이터·대용량 자료를 조회한다. Use when the user asks for 한국 공식 통계 (인구, 가구, 물가, 고용 등) 수치 조회, not for analysis or visualization.", + "profiles": [ + "proxy", + "lookup" + ], + "frontmatter": "name: kosis-stats\ndescription: 국가데이터처가 운영하는 KOSIS(국가통계포털, kosis.kr) Open API로 한국 공식 통계표를 검색하고 메타데이터·데이터·대용량 자료를 조회한다. Use when the user asks for 한국 공식 통계 (인구, 가구, 물가, 고용 등) 수치 조회, not for analysis or visualization.\nlicense: MIT\nmetadata:\n category: data\n locale: ko-KR\n phase: v1" +} diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/tests/__init__.py b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/data_response.json b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/data_response.json new file mode 100644 index 00000000..6f18c79d --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/data_response.json @@ -0,0 +1,28 @@ +[ + { + "TBL_ID": "DT_1JC1501", + "ORG_ID": "101", + "PRD_SE": "Y", + "PRD_DE": "2023", + "ITM_ID": "T001", + "ITM_NM": "1인 가구 비율", + "UNIT_NM": "%", + "C1": "00", + "C1_NM": "전국", + "DT": "35.5", + "TBL_NM": "1인 가구 비율" + }, + { + "TBL_ID": "DT_1JC1501", + "ORG_ID": "101", + "PRD_SE": "Y", + "PRD_DE": "2024", + "ITM_ID": "T001", + "ITM_NM": "1인 가구 비율", + "UNIT_NM": "%", + "C1": "00", + "C1_NM": "전국", + "DT": "36.2", + "TBL_NM": "1인 가구 비율" + } +] diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/meta_response.json b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/meta_response.json new file mode 100644 index 00000000..15f013c6 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/meta_response.json @@ -0,0 +1,6 @@ +[ + { + "TBL_NM": "1인 가구 비율", + "TBL_NM_ENG": "Single-person Household Ratio" + } +] diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/search_response.json b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/search_response.json new file mode 100644 index 00000000..45015ddc --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/fixtures/search_response.json @@ -0,0 +1,17 @@ +[ + { + "ORG_ID": "101", + "ORG_NM": "통계청", + "TBL_ID": "DT_1JC1501", + "TBL_NM": "1인 가구 비율", + "STAT_ID": "A11320180501112233", + "STAT_NM": "인구주택총조사", + "VW_CD": "MT_ZTITLE", + "MT_ATITLE": "주제별 통계 > 인구·가구 > 가구", + "STRT_PRD_DE": "2015", + "END_PRD_DE": "2024", + "TBL_VIEW_URL": "https://kosis.kr/statisticsList/example", + "LINK_URL": "https://kosis.kr/statHtml/statHtml.do?orgId=101&tblId=DT_1JC1501", + "QUERY": "1인 가구" + } +] diff --git a/tools/appcontainer-skill-poc/skills/kosis-stats/tests/test_run_kosis_stats.py b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/test_run_kosis_stats.py new file mode 100644 index 00000000..c5f024b7 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/kosis-stats/tests/test_run_kosis_stats.py @@ -0,0 +1,576 @@ +import importlib.util +import io +import json +import os +import sys +import unittest +from contextlib import redirect_stdout +from pathlib import Path +from unittest import mock + + +SCRIPT_DIR = Path(__file__).resolve().parent +HELPER_PATH = SCRIPT_DIR.parent / "scripts" / "run_kosis_stats.py" +FIXTURES_DIR = SCRIPT_DIR / "fixtures" + + +def load_helper(): + spec = importlib.util.spec_from_file_location("run_kosis_stats", HELPER_PATH) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load helper from {HELPER_PATH}") + module = importlib.util.module_from_spec(spec) + sys.modules["run_kosis_stats"] = module + spec.loader.exec_module(module) + return module + + +helper = load_helper() + + +def read_fixture(name: str) -> str: + return (FIXTURES_DIR / name).read_text(encoding="utf-8") + + +class ParseArgsTest(unittest.TestCase): + def test_search_subcommand_parses_query(self): + args = helper.parse_args(["search", "--query", "인구"]) + self.assertEqual(args.command, "search") + self.assertEqual(args.query, "인구") + self.assertEqual(args.result_count, 20) + + def test_data_subcommand_requires_period_fields(self): + args = helper.parse_args([ + "data", "--table-id", "DT_1JC1501", + "--prd-se", "Y", "--start", "2020", "--end", "2023", + ]) + self.assertEqual(args.prd_se, "Y") + self.assertEqual(args.itm_id, "ALL") + + def test_meta_subcommand_defaults_org_id_to_101(self): + args = helper.parse_args(["meta", "--table-id", "DT_1IN0001"]) + self.assertEqual(args.org_id, "101") + self.assertEqual(args.meta_type, "TBL") + + def test_bigdata_subcommand_requires_user_stats_id(self): + args = helper.parse_args([ + "bigdata", "--user-stats-id", "openapisample/101/DT_1IN1502/2/1/abc", + ]) + self.assertEqual(args.user_stats_id, "openapisample/101/DT_1IN1502/2/1/abc") + self.assertEqual(args.bigdata_format, "json") + + def test_invalid_prd_se_rejected(self): + with self.assertRaises(SystemExit): + helper.parse_args([ + "data", "--table-id", "X", "--prd-se", "X", + "--start", "2020", "--end", "2021", + ]) + + def test_result_count_out_of_range_rejected(self): + with self.assertRaises(SystemExit): + helper.parse_args([ + "search", "--query", "x", "--result-count", "9999", + ]) + + def test_list_subcommand_parses_vw_cd(self): + args = helper.parse_args(["list", "--vw-cd", "MT_ZTITLE"]) + self.assertEqual(args.command, "list") + self.assertEqual(args.vw_cd, "MT_ZTITLE") + self.assertEqual(args.parent_id, "") + + def test_list_invalid_vw_cd_rejected(self): + with self.assertRaises(SystemExit): + helper.parse_args(["list", "--vw-cd", "INVALID_CODE"]) + + def test_explain_requires_stat_id_or_org_tbl(self): + args = helper.parse_args(["explain", "--stat-id", "A10120100413104843"]) + self.assertEqual(args.stat_id, "A10120100413104843") + args2 = helper.parse_args(["explain", "--org-id", "101", "--table-id", "DT_1IN0001"]) + self.assertEqual(args2.org_id, "101") + self.assertEqual(args2.table_id, "DT_1IN0001") + + def test_indicator_defaults_service_to_1(self): + args = helper.parse_args(["indicator", "--jipyo-id", "160"]) + self.assertEqual(args.service, "1") + self.assertEqual(args.jipyo_id, "160") + self.assertEqual(args.page_no, 1) + self.assertEqual(args.num_of_rows, 10) + + def test_indicator_invalid_service_rejected(self): + with self.assertRaises(SystemExit): + helper.parse_args(["indicator", "--jipyo-id", "160", "--service", "9"]) + + +class CredentialResolutionTest(unittest.TestCase): + def test_env_var_takes_precedence(self): + env = {"KSKILL_KOSIS_API_KEY": "env-key"} + secrets = SCRIPT_DIR / "fixtures" / "missing.env" + self.assertEqual(helper.resolve_api_key(env=env, secrets_path=secrets), "env-key") + + def test_secrets_env_used_when_env_var_missing(self): + secrets = SCRIPT_DIR / "fixtures" / "tmp_secrets.env" + secrets.write_text("KSKILL_KOSIS_API_KEY=file-key\n", encoding="utf-8") + try: + self.assertEqual( + helper.resolve_api_key(env={}, secrets_path=secrets), "file-key" + ) + finally: + secrets.unlink() + + def test_missing_credentials_exits_with_helpful_message(self): + secrets = SCRIPT_DIR / "fixtures" / "missing.env" + with self.assertRaises(SystemExit) as ctx: + helper.resolve_api_key(env={}, secrets_path=secrets) + message = str(ctx.exception) + self.assertIn("KSKILL_KOSIS_API_KEY", message) + self.assertIn("kosis.kr/openapi", message) + + def test_proxy_base_url_defaults_to_hosted_proxy(self): + self.assertEqual( + helper.resolve_proxy_base_url(env={}), + "https://k-skill-proxy.nomadamas.org" + ) + + def test_proxy_base_url_env_override_is_trimmed(self): + self.assertEqual( + helper.resolve_proxy_base_url(env={"KSKILL_PROXY_BASE_URL": "https://proxy.example/"}), + "https://proxy.example" + ) + + def test_proxy_base_url_can_be_disabled_for_direct_mode(self): + with self.assertRaises(SystemExit): + helper.resolve_proxy_base_url(env={"KSKILL_PROXY_BASE_URL": "off"}) + + +class UrlBuilderTest(unittest.TestCase): + def test_search_params_include_required_fields(self): + args = helper.parse_args(["search", "--query", "인구"]) + params = helper.build_search_params("KEY", args) + self.assertEqual(params["method"], "getList") + self.assertEqual(params["searchNm"], "인구") + self.assertEqual(params["apiKey"], "KEY") + self.assertEqual(params["format"], "json") + + def test_data_params_include_obj_l_default(self): + args = helper.parse_args([ + "data", "--table-id", "DT_1JC1501", + "--prd-se", "Y", "--start", "2020", "--end", "2023", + ]) + params = helper.build_data_params("KEY", args) + self.assertEqual(params["objL1"], "ALL") + self.assertEqual(params["prdSe"], "Y") + self.assertEqual(params["startPrdDe"], "2020") + self.assertEqual(params["endPrdDe"], "2023") + + def test_data_params_apply_obj_l_overrides(self): + args = helper.parse_args([ + "data", "--table-id", "DT_X", "--prd-se", "Y", + "--start", "2020", "--end", "2020", + "--obj-l", "1=ALL", "--obj-l", "2=00", + ]) + params = helper.build_data_params("KEY", args) + self.assertEqual(params["objL1"], "ALL") + self.assertEqual(params["objL2"], "00") + + def test_obj_l_rejects_bad_index(self): + args = helper.parse_args([ + "data", "--table-id", "DT_X", "--prd-se", "Y", + "--start", "2020", "--end", "2020", "--obj-l", "9=ALL", + ]) + with self.assertRaises(SystemExit): + helper.build_data_params("KEY", args) + + def test_meta_params_include_type_and_table(self): + args = helper.parse_args(["meta", "--table-id", "DT_1IN0001"]) + params = helper.build_meta_params("KEY", args) + self.assertEqual(params["method"], "getMeta") + self.assertEqual(params["type"], "TBL") + self.assertEqual(params["tblId"], "DT_1IN0001") + + def test_bigdata_params_include_format_and_optional_fields(self): + args = helper.parse_args([ + "bigdata", "--user-stats-id", "abc/def", + "--format", "sdmx", "--prd-se", "Y", "--new-est-prd-cnt", "5", + ]) + params = helper.build_bigdata_params("KEY", args) + self.assertEqual(params["format"], "sdmx") + self.assertEqual(params["userStatsId"], "abc/def") + self.assertEqual(params["prdSe"], "Y") + self.assertEqual(params["newEstPrdCnt"], "5") + + def test_build_url_round_trip(self): + url = helper.build_url("https://example.com/x", {"a": "1", "b": "한글"}) + self.assertTrue(url.startswith("https://example.com/x?")) + self.assertIn("a=1", url) + self.assertIn("b=", url) + + def test_list_params_include_vw_cd_and_parent_id(self): + args = helper.parse_args(["list", "--vw-cd", "MT_ZTITLE", "--parent-id", "sub123"]) + params = helper.build_list_params("KEY", args) + self.assertEqual(params["vwCd"], "MT_ZTITLE") + self.assertEqual(params["parentListId"], "sub123") + self.assertEqual(params["method"], "getList") + + def test_explain_params_with_org_and_table(self): + args = helper.parse_args(["explain", "--org-id", "101", "--table-id", "DT_1IN0001"]) + params = helper.build_explain_params("KEY", args) + self.assertEqual(params["orgId"], "101") + self.assertEqual(params["tblId"], "DT_1IN0001") + self.assertEqual(params["metaItm"], "All") + + def test_explain_params_with_stat_id(self): + args = helper.parse_args(["explain", "--stat-id", "A10120100413104843"]) + params = helper.build_explain_params("KEY", args) + self.assertEqual(params["statId"], "A10120100413104843") + self.assertNotIn("orgId", params) + + def test_explain_without_any_id_exits(self): + args = helper.parse_args(["explain"]) + with self.assertRaises(SystemExit): + helper.build_explain_params("KEY", args) + + def test_indicator_params_include_service_and_jipyo_id(self): + args = helper.parse_args(["indicator", "--jipyo-id", "160", "--service", "2"]) + params = helper.build_indicator_params("KEY", args) + self.assertEqual(params["jipyoId"], "160") + self.assertEqual(params["service"], "2") + self.assertEqual(params["serviceDetail"], "pkCalcSource") + self.assertEqual(params["pageNo"], "1") + self.assertEqual(params["numOfRows"], "10") + + +class JsonHandlingTest(unittest.TestCase): + def test_unquoted_keys_are_fixed(self): + text = '{ORG_ID:"101",TBL_ID:"DT_X"}' + fixed = helper.fix_unquoted_keys(text) + self.assertEqual(json.loads(fixed), {"ORG_ID": "101", "TBL_ID": "DT_X"}) + + def test_parse_kosis_json_handles_clean_json(self): + payload = helper.parse_kosis_json('{"a":1,"b":"x"}') + self.assertEqual(payload, {"a": 1, "b": "x"}) + + def test_parse_kosis_json_handles_unquoted_keys(self): + payload = helper.parse_kosis_json('{a:1,b:"x"}') + self.assertEqual(payload, {"a": 1, "b": "x"}) + + +class ErrorDetectionTest(unittest.TestCase): + def test_proxy_not_configured_body_requires_matching_error_code(self): + self.assertTrue(helper.is_proxy_not_configured_body( + '{"error":"upstream_not_configured","message":"missing key"}' + )) + self.assertFalse(helper.is_proxy_not_configured_body( + '{"error":"maintenance","message":"try later"}' + )) + self.assertFalse(helper.is_proxy_not_configured_body("temporarily unavailable")) + + def test_err_field_detected(self): + err = helper.detect_kosis_error({"err": "31", "errMsg": "조회결과 초과"}) + self.assertIsNotNone(err) + self.assertEqual(err.code, "31") + self.assertIn("31", str(err)) + # 31 hint 가 분할 + bigdata + 구체 예시 모두 안내 + self.assertIn("좁히", str(err)) + self.assertIn("obj-l", str(err)) + self.assertIn("bigdata", str(err)) + + def test_errcode_field_detected(self): + err = helper.detect_kosis_error({"errCode": "10", "errMsg": "인증키 누락"}) + self.assertIsNotNone(err) + self.assertEqual(err.code, "10") + self.assertIn("KSKILL_KOSIS_API_KEY", str(err)) + + def test_code_20_hint_directs_to_meta(self): + err = helper.detect_kosis_error({"err": "20", "errMsg": "필수요청변수값이 누락"}) + self.assertIsNotNone(err) + self.assertIn("meta", str(err)) + self.assertIn("--obj-l", str(err)) + + def test_code_21_hint_suggests_search(self): + err = helper.detect_kosis_error({"err": "21", "errMsg": "잘못된 요청"}) + self.assertIsNotNone(err) + self.assertIn("search", str(err)) + + def test_code_30_hint_includes_keyword_relaxation(self): + err = helper.detect_kosis_error({"err": "30", "errMsg": "결과 없음"}) + self.assertIsNotNone(err) + self.assertIn("키워드", str(err)) + self.assertIn("meta-type", str(err)) + + def test_code_30_hint_includes_prd_se_misconfiguration(self): + err = helper.detect_kosis_error({"err": "30", "errMsg": "결과 없음"}) + self.assertIsNotNone(err) + self.assertIn("--prd-se", str(err)) + self.assertIn("PRD_SE", str(err)) + + def test_unknown_code_still_reported(self): + err = helper.detect_kosis_error({"err": "99", "errMsg": "알 수 없음"}) + self.assertIsNotNone(err) + self.assertEqual(err.code, "99") + + def test_normal_payload_returns_none(self): + self.assertIsNone(helper.detect_kosis_error([{"DT": "1"}])) + self.assertIsNone(helper.detect_kosis_error({"OK": True})) + + def test_xml_error_detected(self): + body = '11유효하지않은 인증KEY입니다.' + err = helper.detect_xml_error(body) + self.assertIsNotNone(err) + self.assertEqual(err.code, "11") + self.assertIn("11", str(err)) + + def test_xml_error_returns_none_for_normal_text(self): + self.assertIsNone(helper.detect_xml_error("")) + + +class CallKosisTest(unittest.TestCase): + def test_call_kosis_returns_payload_on_success(self): + with mock.patch.object(helper, "fetch_text", return_value=read_fixture("search_response.json")): + payload = helper.call_kosis("https://example", 5) + self.assertIsInstance(payload, list) + self.assertEqual(payload[0]["TBL_ID"], "DT_1JC1501") + + def test_call_kosis_raises_on_kosis_error_payload(self): + body = json.dumps({"err": "31", "errMsg": "조회결과 초과"}) + with mock.patch.object(helper, "fetch_text", return_value=body): + with self.assertRaises(helper.KosisError) as ctx: + helper.call_kosis("https://example", 5) + self.assertEqual(ctx.exception.code, "31") + + def test_call_kosis_returns_text_for_non_json_format(self): + with mock.patch.object(helper, "fetch_text", return_value=""): + payload = helper.call_kosis("https://example", 5, format_hint="sdmx") + self.assertEqual(payload, "") + + def test_call_kosis_detects_json_error_envelope_in_non_json_format(self): + body = json.dumps({"err": "11", "errMsg": "유효하지 않은 인증KEY입니다."}) + with mock.patch.object(helper, "fetch_text", return_value=body): + with self.assertRaises(helper.KosisError) as ctx: + helper.call_kosis("https://example", 5, format_hint="csv") + self.assertEqual(ctx.exception.code, "11") + + def test_call_kosis_returns_csv_text_when_no_error_envelope(self): + body = "PRD_DE,DT\n2024,1\n" + with mock.patch.object(helper, "fetch_text", return_value=body): + payload = helper.call_kosis("https://example", 5, format_hint="csv") + self.assertEqual(payload, body) + + def test_bigdata_format_xls_is_rejected(self): + with self.assertRaises(SystemExit): + helper.parse_args([ + "bigdata", "--user-stats-id", "abc/def", "--format", "xls", + ]) + + +class RenderTextTest(unittest.TestCase): + def test_search_text_lists_each_table(self): + payload = json.loads(read_fixture("search_response.json")) + text = helper.render_search_text(payload) + self.assertIn("DT_1JC1501", text) + self.assertIn("1인 가구 비율", text) + + def test_search_empty_renders_friendly_message(self): + text = helper.render_search_text([]) + self.assertIn("결과가 없습니다", text) + self.assertIn("키워드", text) + self.assertIn("--start-count", text) + + def test_search_text_includes_next_step_hint(self): + payload = json.loads(read_fixture("search_response.json")) + text = helper.render_search_text(payload) + self.assertIn("Next", text) + self.assertIn("meta", text) + self.assertIn("data", text) + + def test_meta_empty_suggests_other_meta_type(self): + text = helper.render_meta_text([]) + self.assertIn("--meta-type", text) + self.assertIn("TBL", text) + + def test_data_empty_suggests_filter_relaxation(self): + text = helper.render_data_text([]) + self.assertIn("--obj-l", text) + self.assertIn("meta", text) + + def test_data_text_includes_summary_with_period_and_unit(self): + payload = json.loads(read_fixture("data_response.json")) + text = helper.render_data_text(payload) + self.assertIn("[summary]", text) + self.assertIn("rows=2", text) + self.assertIn("period=2023~2024", text) + self.assertIn("unit=%", text) + + def test_data_text_summary_marks_missing_unit(self): + text = helper.render_data_text([{"PRD_DE": "2024", "ITM_NM": "x", "DT": "1"}]) + self.assertIn("UNIT_NM 미포함", text) + + def test_meta_text_includes_korean_and_english(self): + payload = json.loads(read_fixture("meta_response.json")) + text = helper.render_meta_text(payload) + self.assertIn("1인 가구 비율", text) + self.assertIn("Single-person", text) + + def test_data_text_lists_period_and_value(self): + payload = json.loads(read_fixture("data_response.json")) + text = helper.render_data_text(payload) + self.assertIn("2023", text) + self.assertIn("35.5", text) + self.assertIn("%", text) + + def test_list_empty_suggests_parent_id_or_vw_cd(self): + text = helper.render_list_text([]) + self.assertIn("--parent-id", text) + self.assertIn("--vw-cd", text) + + def test_list_text_shows_category_entries(self): + payload = [ + {"LIST_ID": "L1", "LIST_NM": "인구/가구", "ORG_ID": "", "TBL_ID": "", "TBL_NM": "", "SEND_DE": ""}, + {"LIST_ID": "L2", "LIST_NM": "경제/산업", "ORG_ID": "101", "TBL_ID": "DT_X", "TBL_NM": "GDP", "SEND_DE": "20240101"}, + ] + text = helper.render_list_text(payload) + self.assertIn("인구/가구", text) + self.assertIn("GDP", text) + self.assertIn("DT_X", text) + + def test_explain_empty_suggests_search(self): + text = helper.render_explain_text([]) + self.assertIn("search", text) + + def test_explain_text_shows_fields(self): + payload = [{"statsNm": "인구총조사", "writingPurps": "인구 파악", "statsPeriod": "5년"}] + text = helper.render_explain_text(payload) + self.assertIn("인구총조사", text) + self.assertIn("인구 파악", text) + self.assertIn("조사주기", text) + + def test_indicator_empty_suggests_jipyo_id(self): + text = helper.render_indicator_text([]) + self.assertIn("--jipyo-id", text) + + def test_indicator_text_shows_concept(self): + payload = [{"statJipyoId": "160", "statJipyoNm": "인구밀도", "jipyoExplan": "인구밀도 설명", "jipyoExplan1": "1km²당 인구수"}] + text = helper.render_indicator_text(payload) + self.assertIn("160", text) + self.assertIn("인구밀도", text) + self.assertIn("1km²당 인구수", text) + + def test_indicator_text_shows_calculation_and_source(self): + payload = [{ + "statJipyoId": "160", + "statJipyoNm": "인구밀도", + "jipyoExplan2": "총인구를 면적으로 나눔", + "jipyoExplan3": "KOSIS", + }] + text = helper.render_indicator_text(payload) + self.assertIn("산정방법", text) + self.assertIn("총인구를 면적으로 나눔", text) + self.assertIn("출처", text) + self.assertIn("KOSIS", text) + + +class DryRunTest(unittest.TestCase): + def test_dry_run_redacts_api_key_and_does_not_call_network(self): + args = helper.parse_args(["search", "--query", "인구", "--dry-run", "--json"]) + with mock.patch.object(helper, "fetch_text") as fetch_mock: + buf = io.StringIO() + with redirect_stdout(buf): + rc = helper.run(args) + self.assertEqual(rc, 0) + fetch_mock.assert_not_called() + out = buf.getvalue() + self.assertIn('"via_proxy": true', out) + self.assertNotIn("apiKey", json.dumps(json.loads(out)["params"])) + self.assertIn("/v1/kosis/search", out) + self.assertIn("statisticsSearch.do", out) + + def test_direct_dry_run_redacts_api_key(self): + args = helper.parse_args(["search", "--query", "인구", "--dry-run", "--direct", "--json"]) + with mock.patch.object(helper, "fetch_text") as fetch_mock: + buf = io.StringIO() + with redirect_stdout(buf): + rc = helper.run(args) + self.assertEqual(rc, 0) + fetch_mock.assert_not_called() + out = buf.getvalue() + self.assertIn("", out) + self.assertIn('"via_proxy": false', out) + self.assertIn("apiKey", out) + + def test_list_dry_run_uses_proxy_and_no_api_key(self): + args = helper.parse_args(["list", "--vw-cd", "MT_ZTITLE", "--dry-run", "--json"]) + with mock.patch.object(helper, "fetch_text") as fetch_mock: + buf = io.StringIO() + with redirect_stdout(buf): + rc = helper.run(args) + self.assertEqual(rc, 0) + fetch_mock.assert_not_called() + out = buf.getvalue() + self.assertIn('"via_proxy": true', out) + self.assertIn("/v1/kosis/list", out) + self.assertIn("statisticsList.do", out) + + def test_explain_dry_run_uses_proxy(self): + args = helper.parse_args(["explain", "--org-id", "101", "--table-id", "DT_1IN0001", "--dry-run", "--json"]) + with mock.patch.object(helper, "fetch_text") as fetch_mock: + buf = io.StringIO() + with redirect_stdout(buf): + rc = helper.run(args) + self.assertEqual(rc, 0) + fetch_mock.assert_not_called() + out = buf.getvalue() + self.assertIn('"via_proxy": true', out) + self.assertIn("/v1/kosis/explain", out) + self.assertIn("statisticsExplData.do", out) + + def test_indicator_dry_run_uses_proxy(self): + args = helper.parse_args(["indicator", "--jipyo-id", "160", "--dry-run", "--json"]) + with mock.patch.object(helper, "fetch_text") as fetch_mock: + buf = io.StringIO() + with redirect_stdout(buf): + rc = helper.run(args) + self.assertEqual(rc, 0) + fetch_mock.assert_not_called() + out = buf.getvalue() + self.assertIn('"via_proxy": true', out) + self.assertIn("/v1/kosis/indicator", out) + self.assertIn("pkNumberService.do", out) + + +class RunIntegrationTest(unittest.TestCase): + def test_run_search_text_renders_fixture_payload(self): + args = helper.parse_args(["search", "--query", "1인 가구", "--text"]) + with mock.patch.object(helper, "fetch_text", return_value=read_fixture("search_response.json")) as fetch_mock: + buf = io.StringIO() + with redirect_stdout(buf): + rc = helper.run(args) + self.assertEqual(rc, 0) + out = buf.getvalue() + self.assertIn("DT_1JC1501", out) + self.assertIn("statisticsSearch.do", out) + self.assertIn("/v1/kosis/search", fetch_mock.call_args.args[0]) + self.assertNotIn("apiKey=", fetch_mock.call_args.args[0]) + + def test_run_returns_2_on_kosis_error(self): + args = helper.parse_args(["data", "--table-id", "DT_X", + "--prd-se", "Y", "--start", "2020", "--end", "2024", "--json"]) + body = json.dumps({"err": "31", "errMsg": "조회결과 초과"}) + with mock.patch.object(helper, "resolve_api_key", return_value="KEY"), \ + mock.patch.object(helper, "fetch_text", return_value=body): + rc = helper.run(args) + self.assertEqual(rc, 2) + + +@unittest.skipUnless(os.getenv("KSKILL_KOSIS_API_KEY"), "live KOSIS test skipped without KSKILL_KOSIS_API_KEY") +class LiveKosisSmokeTest(unittest.TestCase): + def test_live_search_returns_list(self): + args = helper.parse_args(["search", "--query", "인구", "--result-count", "1", "--json", "--direct"]) + buf = io.StringIO() + with redirect_stdout(buf): + rc = helper.run(args) + self.assertEqual(rc, 0) + payload = json.loads(buf.getvalue()) + self.assertIsInstance(payload, list) + self.assertGreaterEqual(len(payload), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/.github/workflows/test.yml b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/.github/workflows/test.yml new file mode 100644 index 00000000..cc941e41 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/.github/workflows/test.yml @@ -0,0 +1,27 @@ +name: Calculator tests + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ['3.11', '3.14'] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ matrix.python-version }} + - name: Run standard-library tests + run: python -m unittest discover -s tests -v diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/.gitignore b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/.gitignore new file mode 100644 index 00000000..85f112e3 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +.venv/ +venv/ +.env +.env.* +.coverage +htmlcov/ +.pytest_cache/ +.DS_Store +Thumbs.db diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/CONTRIBUTING.ko.md b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/CONTRIBUTING.ko.md new file mode 100644 index 00000000..587209bc --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/CONTRIBUTING.ko.md @@ -0,0 +1,41 @@ +# 독립 구현 계산기의 기여 기준 + +[English](CONTRIBUTING.md) | [README](README.ko.md) | [저작권 안내](docs/COPYRIGHT.ko.md) + +이 프로젝트는 사용자가 제공한 값을 계산하는 표준 라이브러리 기반의 독립 구현을 받습니다. 유지 관리자는 각 기여의 범위, 출처, 라이선스와 테스트를 검토한 뒤 반영합니다. + +## 외부 서비스와 데이터셋 없는 계산 + +공개 계산 방법, 입력값, 단위, 가정과 반올림 기준을 설명합니다. 실제 JSON 입력과 출력, 예제를 [계산 연산 안내](references/operations.md)에 추가합니다. Python 3.11 이상을 지원하며 다운로드, 네트워크 요청, API 키, 환경 변수로 받는 자격 증명과 pip 의존성을 추가하지 않습니다. + +세금과 규제 계산, 법정 급여, 최신 금리와 시세, 공휴일과 음력, 지역별 일광절약시간 데이터, 의학적 분류, 진단과 치료를 제외합니다. 기본 BMI는 비교 집단이나 임상 분류를 가져오지 않고 수치만 구할 수 있습니다. 특정 사이트의 PRO 업무 흐름, 분석과 보고서를 복제하지 않습니다. + +## 기여별 출처와 권리 확인 + +PR에는 다음 내용을 포함합니다. + +- 직접 구현한 작성자와 참고 자료 +- 공개 공식이나 사양의 1차 출처 링크와 해당 버전 또는 기준일 +- 제안한 비원본 자료 각각의 라이선스와 이용 조건 또는 비원본 자료를 포함하지 않았다는 설명 +- 구현, 가정, 반올림과 테스트 사례를 독립적으로 도출한 경위 +- 변경 동작의 테스트 명령과 결과 + +공개된 자료나 익숙한 공식이라고 해서 다른 구현을 복제할 권한까지 얻지는 않습니다. 제3자의 소스 코드, 화면 구성, 도움말, 보고서, 예제와 데이터셋을 복제, 번역, 문장 바꾸기나 코드 재작성으로 가져오지 않습니다. 공개 수학 방법을 출처로 삼고 구현, 인터페이스와 설명은 독립적으로 작성합니다. PRO나 상표 표시만으로 특정 서비스가 일반 계산 방법을 소유한다고 주장하지 않습니다. [저작권 안내](docs/COPYRIGHT.ko.md)는 구분 기준을 설명하며 개별 구현의 법적 적합성을 보증하지 않습니다. + +유지 관리자는 출처나 권리를 판단하기 어려운 자료에 추가 근거를 요청하거나 반영을 보류할 수 있습니다. 출처 표시만으로 제외 대상 자료를 허용하지 않습니다. 실제 자격 증명, 개인 금융 기록이나 건강정보를 코드, 테스트 자료, 문서, 이슈와 PR에 포함하지 않습니다. + +## 검토 전 동작 검증 + +대표 결과, 잘못된 입력, 계산 범위와 반올림처럼 변경에 관련된 동작을 테스트합니다. 합성 입력과 독립적으로 도출한 예상값을 사용합니다. 테스트에서 제3자 계산기 서비스를 수집하거나 정답 비교 대상으로 호출하지 않습니다. + +저장소 루트에서 표준 라이브러리 테스트를 실행할 수 있습니다. + +```text +python -m unittest discover -s tests -v +``` + +[워크플로](.github/workflows/test.yml)는 Linux와 Windows에서 Python 3.11, 3.14를 검사합니다. [SKILL.md](SKILL.md), 계산 연산 안내와 두 언어의 README에 실제 구현과 같은 입력, 출력과 제한을 유지합니다. + +## 기존 라이선스와 저작자 표시 + +[LICENSE-AGPL](LICENSE-AGPL), [LICENSE-COMMERCIAL](LICENSE-COMMERCIAL)과 기존 저작자 표시를 보존합니다. 라이선스 예외는 반영 전에 유지 관리자와 협의합니다. PR만으로 별도 상용 계약을 체결하거나 제3자의 권리를 변경하지 않습니다. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/CONTRIBUTING.md b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/CONTRIBUTING.md new file mode 100644 index 00000000..988a0310 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/CONTRIBUTING.md @@ -0,0 +1,41 @@ +# Contributing independent calculator implementations + +[한국어](CONTRIBUTING.ko.md) | [README](README.md) | [Copyright policy](docs/COPYRIGHT.md) + +This project accepts independently written, standard-library-only calculations over explicit user inputs. Maintainers review each contribution's scope, provenance, licensing and tests before inclusion. + +## Scope without external services or datasets + +Describe the public calculation method, required inputs, units, assumptions and rounding. Extend the [operation reference](references/operations.md) with the actual JSON contract and examples. Support Python 3.11 or later without downloads, network requests, API keys, environment-provided credentials or pip dependencies. + +Tax and regulatory calculations, statutory benefits, current rates or prices, holiday and lunar calendars, named-zone DST datasets, medical classifications, diagnosis and treatment are outside scope. Basic BMI can calculate the numerical index without importing comparison populations or clinical categories. Do not reproduce site-specific PRO workflows, analyses or reports. + +## Provenance and rights for each contribution + +Include the following in the pull request: + +- Original implementation authorship and any source material consulted +- Links to primary public formula or specification sources, with the relevant version or date +- The license and usage terms of every proposed non-original item, or an explicit statement that no non-original material is included +- Independent derivation of the implementation, assumptions, rounding and test cases +- Test commands and results for the changed behavior + +Public visibility and a familiar formula do not grant permission to copy another implementation. Do not copy, translate, paraphrase or rewrite third-party source code, interface layouts, help text, reports, examples or datasets into a contribution. Cite public mathematical methods and write the implementation, interface and explanation independently. Do not claim a service owns a general method merely because it uses a PRO or branded label. The [copyright policy](docs/COPYRIGHT.md) describes the boundary without promising legal clearance. + +Maintainers can request further evidence or decline material whose origin or rights remain unresolved. Attribution alone does not make otherwise excluded material acceptable. Do not submit actual credentials, private financial records or personal health information in code, fixtures, documentation, issues or pull requests. + +## Validation before review + +Add behavior tests for representative results, invalid inputs, domain limits and rounding where relevant. Use synthetic values and independently derived expectations. Tests should not scrape or compare against a third-party calculator service. + +Run the standard-library tests from the repository root: + +```text +python -m unittest discover -s tests -v +``` + +The [workflow](.github/workflows/test.yml) covers Python 3.11 and 3.14 on Linux and Windows. Keep [SKILL.md](SKILL.md), the operation reference and both README languages consistent with the implemented behavior. + +## Existing license notices + +Preserve [LICENSE-AGPL](LICENSE-AGPL), [LICENSE-COMMERCIAL](LICENSE-COMMERCIAL) and existing attribution. Discuss any licensing exception with maintainers before inclusion. A pull request does not create a separate commercial agreement or override third-party rights. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/LICENSE-AGPL b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/LICENSE-AGPL new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/LICENSE-AGPL @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/LICENSE-COMMERCIAL b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/LICENSE-COMMERCIAL new file mode 100644 index 00000000..5ce945d9 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/LICENSE-COMMERCIAL @@ -0,0 +1,3 @@ +If you wish to use the source code of this project for commercial purposes, you must consult with the main developer of this project, Jung Hyun, Nam. + +He can be reached at rkttu at rkttu dot com. His time zone is GMT+9 Asia/Seoul. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/README.ko.md b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/README.ko.md new file mode 100644 index 00000000..73ce4762 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/README.ko.md @@ -0,0 +1,52 @@ +# 식탁보 금융 및 생활 계산기 + +[English](README.md) | [저작권과 독립 구현 기준](docs/COPYRIGHT.ko.md) | [기여 안내](CONTRIBUTING.ko.md) + +`tablecloth-calculators`는 Codex 스킬과 로컬 Python 계산기를 제공합니다. 표시 이름으로 **금융 및 생활 계산기**를 사용합니다. 계산기는 사용자가 제공한 값으로 계산하며 웹 요청, 외부 데이터셋, API 키나 외부 Python 패키지를 사용하지 않습니다. + +공개 계산 방법을 바탕으로 구현합니다. 제3자의 소스 코드, 화면, 설명문, 보고서와 데이터셋은 이 패키지의 재사용 범위에서 제외합니다. [저작권 안내](docs/COPYRIGHT.ko.md)에서 독립 구현과 기여 기준을 설명합니다. + +## 입력값과 계산 범위 + +[계산 연산 안내](references/operations.md)는 지원 연산, JSON 필드, 단위, 가정과 반올림 기준을 제공합니다. 금융 계산은 사용자가 제시한 수치로 산술 결과를 구하며 개인별 금융 상품이나 투자를 추천하지 않습니다. 기본 BMI 계산은 수치만 반환하고 건강 분류나 인구집단 비교를 제공하지 않습니다. + +세금, 법정 급여, 규제 적격성, 실시간 금리와 시세, 공휴일과 음력, 지역별 일광절약시간 데이터, 의학적 해석, 특정 사이트의 PRO 분석과 보고서를 제외합니다. PRO 표시만으로 공개 계산 방법에 대한 법적 독점권을 판단하지 않습니다. + +## 저장소 루트의 스킬 설치 + +식탁보의 설치된 스킬 화면에서 이 폴더를 가져올 수 있습니다. 카탈로그에 등록한 뒤에는 스킬 마켓플레이스에서도 설치할 수 있습니다. 루트에 [SKILL.md](SKILL.md)를 두므로 카탈로그는 `Source.Path="."`와 전체 커밋 해시를 사용합니다. 스킬 설치는 Python 설치나 전역 설정 변경을 수행하지 않습니다. + +계산기는 Python 3.11 이상에서 실행합니다. 식탁보에서는 전용 Python 런타임을 설치하고 `KSKILL_PYTHON`으로 전달한 절대 경로를 사용합니다. 식탁보 외부에서는 실행할 Python의 절대 경로를 직접 지정합니다. pip 패키지나 추가 서비스 자격 증명을 요구하지 않습니다. + +## 계산 요청과 실행 권한 + +백분율이나 할인액처럼 지원하는 계산을 Codex에 요청할 수 있습니다. [스킬 지침](SKILL.md)은 요청을 [scripts/calculate.py](scripts/calculate.py)로 전달하며 표준 입력으로 JSON 요청 하나를 보냅니다. 식탁보가 `KSKILL_PYTHON`을 전달한 실행 환경에서 다음 PowerShell 예제를 사용할 수 있습니다. 스크립트 경로는 설치한 스킬의 절대 경로로 바꿉니다. + +```powershell +$request = '{"operation":"discount.single","price":"100","rate_percent":"20"}' +$request | & $env:KSKILL_PYTHON -I -S -B 'C:\absolute\skill\scripts\calculate.py' +``` + +해당 변수가 없는 별도 터미널에서는 `$env:KSKILL_PYTHON` 대신 선택한 Python의 절대 경로를 사용합니다. `-I -S -B`는 Python 가져오기 경로를 격리하고 site 초기화와 바이트코드 파일 생성을 생략합니다. 이 요청은 `ok: true`와 `result.final`의 `"80.00"`을 반환합니다. 소수 문자열로 숫자를 전달하면 입력 단계의 의도하지 않은 반올림을 줄일 수 있습니다. [계산 연산 안내](references/operations.md)에서 전체 입력과 출력을 확인할 수 있습니다. 잘못된 입력에는 `ok: false`와 종료 코드 2를 반환합니다. 실행에 실패하면 제한이나 수정할 입력값을 설명하며 추측한 결과를 제시하지 않습니다. + +계산 CLI는 네트워크 요청을 보내지 않습니다. 다만 Codex 대화는 온라인 계정 연결을 사용하고 대화 내용을 공급자에게 전송할 수 있습니다. 셸 실행 권한은 이 계산 스크립트보다 넓은 실행 범위를 허용하며 스킬 지침만으로 Python 전용 보안 경계를 강제할 수 없습니다. 식탁보의 실행 권한을 그대로 적용합니다. + +## 기여 기준과 로컬 검증 + +기여 과정에서는 [기여 안내](CONTRIBUTING.ko.md)에 따라 출처, 라이선스와 독립 구현 경위를 밝히고 유지 관리자의 검토를 거칩니다. 사이트 콘텐츠의 복제나 번역, 제3자 코드의 재작성, PRO 서비스 복제와 데이터 표 추출을 제출하지 않습니다. + +저장소 루트에서 Python 3.11 이상으로 표준 라이브러리 테스트를 실행할 수 있습니다. + +```text +python -m unittest discover -s tests -v +``` + +[CI 워크플로](.github/workflows/test.yml)는 Linux와 Windows에서 Python 3.11, 3.14로 같은 테스트를 실행합니다. 패키지 게시나 서비스 배포는 수행하지 않습니다. + +## 원프로젝트의 라이선스 보존 + +이 패키지는 식탁보의 [AGPL 라이선스 전문](LICENSE-AGPL)과 [상용 라이선스 문의 안내](LICENSE-COMMERCIAL)를 원문 그대로 포함합니다. 원프로젝트는 다음 문구를 사용합니다. + +> Copyright 2021-2026 rkttu.com. All rights reserved. + +상용 문의 안내는 Jung Hyun, Nam을 연락 대상으로 명시합니다. 기여자가 제공하는 출처와 라이선스 정보는 유지 관리자 검토에 사용하며 기존 라이선스를 대체하거나 제3자 자료의 권리를 부여하지 않습니다. [저작권 안내](docs/COPYRIGHT.ko.md)에서 적용 범위를 확인할 수 있습니다. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/README.md b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/README.md new file mode 100644 index 00000000..cb69223f --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/README.md @@ -0,0 +1,52 @@ +# TableCloth financial and everyday calculators + +[한국어](README.ko.md) | [Copyright and independent implementation](docs/COPYRIGHT.md) | [Contributing](CONTRIBUTING.md) + +`tablecloth-calculators` provides a Codex skill and a local Python calculator for financial and everyday arithmetic. Its Korean display name is **금융 및 생활 계산기**. The calculator uses supplied values without web requests, external datasets, API keys or third-party Python packages. + +Public calculation methods guide the implementation. Third-party source code, interfaces, explanatory text, reports and datasets are outside this package's reuse scope. The [copyright policy](docs/COPYRIGHT.md) explains the distinction and contribution requirements. + +## Supported calculations and explicit inputs + +The maintained [operation reference](references/operations.md) lists supported operations, JSON fields, units, assumptions and rounding. Calculations cover financial and everyday quantities rather than personalized financial recommendations. Basic BMI returns an arithmetic index without health classifications or population comparisons. + +This package excludes taxes, statutory benefits, regulatory eligibility, live rates and prices, holiday or lunar calendars, named-zone daylight-saving datasets, medical interpretation and site-specific PRO analysis or reports. A PRO label alone does not establish exclusive legal rights in a public calculation method. + +## Installation from the repository root + +Import this folder through TableCloth's installed-skills view, or install it from the skill marketplace after a catalog lists this repository. The root [SKILL.md](SKILL.md) makes the catalog path `Source.Path="."`; a catalog entry also pins a full commit. Installing this skill does not install Python or change global settings. + +The command-line calculator requires Python 3.11 or later. With TableCloth, install its dedicated Python runtime and use the absolute path supplied in `KSKILL_PYTHON`. Outside TableCloth, select an absolute interpreter path explicitly. No pip dependencies or additional service credentials are required. + +## Calculation requests and execution boundaries + +Ask Codex for a supported calculation, such as a percentage or discount. The [skill instructions](SKILL.md) route the request to [scripts/calculate.py](scripts/calculate.py), using one JSON request on stdin. In an execution environment where TableCloth supplies `KSKILL_PYTHON`, replace the script path in this PowerShell example with the installed skill's absolute path: + +```powershell +$request = '{"operation":"discount.single","price":"100","rate_percent":"20"}' +$request | & $env:KSKILL_PYTHON -I -S -B 'C:\absolute\skill\scripts\calculate.py' +``` + +In a separate terminal without that variable, use the chosen interpreter's absolute path in place of `$env:KSKILL_PYTHON`. The flags `-I -S -B` isolate Python imports, skip site initialization and suppress bytecode writes. This request returns `ok: true` with `result.final` equal to `"80.00"`. Decimal strings avoid unintended input rounding. The [operation reference](references/operations.md) defines the full contract. Invalid input returns `ok: false` and exit code 2. A failed execution produces a limitation or input correction, not a guessed result. + +The calculator CLI makes no network requests. A Codex chat can still require an online account and transmit conversation content to its provider. Shell permission enables a broader execution surface than this script: the skill's instructions do not enforce a Python-only security boundary. TableCloth's permissions remain applicable. + +## Contributions and local validation + +Contributions follow [CONTRIBUTING.md](CONTRIBUTING.md), including provenance, licensing, independent implementation and maintainer review. Do not submit copied or translated site content, rewritten third-party code, PRO clones or extracted data tables. + +Run the standard-library test suite from the repository root with Python 3.11 or later: + +```text +python -m unittest discover -s tests -v +``` + +The [CI workflow](.github/workflows/test.yml) runs this command on Linux and Windows with Python 3.11 and 3.14. It does not publish packages or deploy services. + +## Original project licensing + +This package includes TableCloth's [AGPL license text](LICENSE-AGPL) and [commercial-license contact notice](LICENSE-COMMERCIAL) unchanged. The original notice states: + +> Copyright 2021-2026 rkttu.com. All rights reserved. + +The commercial notice names Jung Hyun, Nam as the contact. A contribution's provenance and license information supports maintainer review; it does not replace the licenses or grant rights in third-party material. See the [copyright policy](docs/COPYRIGHT.md). diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/SKILL.md b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/SKILL.md new file mode 100644 index 00000000..154938b1 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/SKILL.md @@ -0,0 +1,23 @@ +--- +name: tablecloth-calculators +description: Calculate financial and everyday quantities from user-supplied values, including percentages, discounts, interest, repayments, dates, fixed-offset time differences, unit conversions and basic BMI arithmetic. Use when the user requests a supported calculation. Excludes tax or regulatory advice, current market data, holiday or lunar calendars, named-zone DST rules and medical interpretation. +license: See LICENSE-AGPL and LICENSE-COMMERCIAL. +metadata: + short-description: Financial and everyday calculations without external data +--- + +# Financial and everyday calculators + +Korean display name: 금융 및 생활 계산기. + +Read [references/operations.md](references/operations.md) for supported operations, exact input fields, units, rounding and examples. Select the operation that matches the request; ask for missing inputs or assumptions instead of inventing them. + +Run the existing [scripts/calculate.py](scripts/calculate.py) with Python 3.11 or later and the flags `-I -S -B`. In TableCloth, use the absolute interpreter path supplied in `KSKILL_PYTHON`. Otherwise use an explicitly configured absolute Python interpreter path. Pass the absolute script path and send one JSON request through stdin. Do not substitute `python` from PATH when TableCloth supplies a dedicated interpreter. `-B` suppresses bytecode writes even when isolated mode ignores Python environment variables. + +Use only this packaged calculator script. Do not generate or evaluate Python expressions, use `eval`, execute `python -c`, install packages, invoke unrelated shell commands, read unrelated files, browse the web or request API keys. The calculator uses only the Python standard library and user-supplied values. + +Explain the returned result with its units, assumptions and rounding. If execution is unavailable, exits unsuccessfully or returns an error, report that limitation or obtain corrected inputs. Do not guess a result or present an unexecuted calculation as verified. + +Keep basic BMI output to the numerical index. Do not infer health categories, diagnoses, treatment, reference percentiles or pregnancy and pediatric results. Use fixed UTC offsets only for time conversion; do not infer a named zone's daylight-saving rules. Reject unsupported tax, statutory benefit, regulatory eligibility, current-price and holiday or lunar-calendar requests. + +The skill adds instructions, not a Python-only sandbox. TableCloth's execution permissions still apply. Follow the [copyright and contribution boundaries](docs/COPYRIGHT.md) when modifying the package. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/docs/COPYRIGHT.ko.md b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/docs/COPYRIGHT.ko.md new file mode 100644 index 00000000..281fb3f4 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/docs/COPYRIGHT.ko.md @@ -0,0 +1,29 @@ +# 계산기 독립 구현의 저작권 경계 + +[English](COPYRIGHT.md) | [README](../README.ko.md) | [기여 안내](../CONTRIBUTING.ko.md) + +2026년 10월 25일 기준으로 이 프로젝트는 공개된 금융과 생활 계산 방법을 독립적인 코드와 문서로 구현합니다. 계산기 사이트의 코드, 화면, 설명문, 보고서나 데이터셋을 가져오지 않습니다. 이 안내는 기여 범위를 정하며 개별 구현의 적법성을 판단하지 않습니다. + +## 계산 방법과 구체적인 표현의 구분 + +한국 저작권법은 창작적인 표현을 저작물로 정의하고 프로그램의 언어, 규약과 해법을 구분합니다. 제101조의2는 적용 대상에서 제외하는 해법을 지시와 명령의 조합방법으로 설명합니다. 따라서 공개 계산 방법과 타인의 구체적인 소스 코드, 문장, 그림이나 표현 형식을 구분할 수 있습니다. 이 구분만으로 해당 자료를 복제할 권한을 얻지는 않습니다. [제2조](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=00&joNo=0002&lsiSeq=283335&urlMode=lsScJoRltInfoR)와 [제101조의2](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=02&joNo=0101&lsiSeq=283335&urlMode=lsScJoRltInfoR)에서 해당 내용을 확인할 수 있습니다. + +기여자는 공개 공식이나 1차 사양을 인용하고 코드, 설명과 테스트를 독립적으로 도출할 수 있습니다. 제3자의 구현이나 설명을 복제, 번역, 문장 바꾸기나 코드 재작성으로 패키지에 가져오는 방식은 접수 범위에서 제외합니다. + +## 계산 방법과 데이터 집합의 구분 + +수치 계산 방법을 이용할 수 있다고 해서 표, 비교 집단, 콘텐츠 모음이나 서비스 데이터베이스의 이용 권한까지 얻지는 않습니다. 제93조는 보호 대상 데이터베이스의 전부 또는 상당 부분의 복제와 배포 등을 다루며 일부 자료의 반복적이거나 체계적인 이용에도 조건을 둡니다. 제2조는 소재의 창작적인 선택, 배열과 구성을 별도로 정의합니다. [제93조](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=00&joNo=0093&lsiSeq=283335&urlMode=lsScJoRltInfoR)와 [제2조](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=00&joNo=0002&lsiSeq=283335&urlMode=lsScJoRltInfoR)를 근거로 구분합니다. + +이 패키지는 사용자가 제공한 값을 계산하며 사이트에서 추출한 데이터셋을 포함하지 않습니다. 기본 BMI 계산에서 인구집단 백분위나 의학적 분류를 제공하지 않습니다. 공휴일, 음력, 최신 시세와 지역별 일광절약시간 데이터셋도 범위에서 제외합니다. + +## 사이트 고유 서비스와 PRO 표시 + +제3자의 화면, 도움말, 보고서 형식, 고유 점수, 데이터셋과 사이트별 PRO 업무 흐름을 복제하지 않습니다. PRO 표시, 공개 접근이나 저작권 주장만으로 일반 계산 방법에 대한 법적 독점권을 판단하지 않습니다. 표시가 없는 경우에도 자료의 재사용을 허락했다고 해석하지 않습니다. + +공개 방법의 독립 구현으로 프로젝트 범위를 정하지만 특허, 상표, 계약과 부정경쟁 등 모든 권리 문제의 해소를 보증하지 않습니다. 특정 서비스의 독점권 유무나 개별 기여의 법적 적합성을 단정하지 않습니다. [제101조의2](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=02&joNo=0101&lsiSeq=283335&urlMode=lsScJoRltInfoR)의 구분만으로 별도 권리 문제까지 판단할 수는 없습니다. + +## 기여별 출처 검토와 프로젝트 라이선스 + +각 기여는 [기여 안내](../CONTRIBUTING.ko.md)에 따라 작성자, 공개 계산 방법의 출처, 포함 자료의 라이선스와 이용 조건, 독립 구현 경위를 기록합니다. 유지 관리자는 반영 전에 범위와 확인하지 못한 권리 문제를 검토합니다. 출처 표시만으로 이용 허락을 대체하거나 제외 대상 자료를 허용하지 않습니다. + +이 패키지는 식탁보의 [AGPL 라이선스 전문](../LICENSE-AGPL), [상용 라이선스 문의 안내](../LICENSE-COMMERCIAL)와 원래의 저작자 표시를 원문 그대로 유지합니다. 이 파일은 프로젝트의 라이선스를 안내하며 제3자 자료의 권리를 부여하지 않습니다. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/docs/COPYRIGHT.md b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/docs/COPYRIGHT.md new file mode 100644 index 00000000..3e022c10 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/docs/COPYRIGHT.md @@ -0,0 +1,29 @@ +# Copyright boundaries for independent calculations + +[한국어](COPYRIGHT.ko.md) | [README](../README.md) | [Contributing](../CONTRIBUTING.md) + +As of October 25, 2026, this project implements public financial and everyday calculation methods with original code and documentation. It does not import calculator-site code, screens, explanations, reports or datasets. This policy sets contribution boundaries; it does not determine the legality of a particular implementation. + +## Methods and concrete expression + +Korea's Copyright Act defines copyright works as creative expression and distinguishes a program's languages, conventions and algorithms from its protected expression. Article 101-2 describes the excluded algorithm as the method of combining instructions and commands. A public calculation method therefore differs from someone else's concrete source code, wording, graphics or presentation. This distinction does not authorize copying those materials. See [Article 2](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=00&joNo=0002&lsiSeq=283335&urlMode=lsScJoRltInfoR) and [Article 101-2](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=02&joNo=0101&lsiSeq=283335&urlMode=lsScJoRltInfoR). + +Contributions can cite a public formula or primary specification and independently derive code, explanations and tests. Copying, translating, paraphrasing or rewriting a third-party implementation or its accompanying content into this package is outside the accepted scope. + +## Datasets and collections + +A numerical method does not grant reuse rights over tables, reference populations, content collections or a service's database. Article 93 covers reproduction and distribution of all or a substantial part of a protected database and addresses certain repeated or systematic uses of smaller portions. Article 2 separately defines creative selection, arrangement and structure. See [Article 93](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=00&joNo=0093&lsiSeq=283335&urlMode=lsScJoRltInfoR) and [Article 2](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=00&joNo=0002&lsiSeq=283335&urlMode=lsScJoRltInfoR). + +This package operates on explicit inputs and includes no extracted site datasets. Basic BMI arithmetic does not include population percentiles or medical classifications. Holiday, lunar-calendar, current-price and named-zone DST datasets remain outside scope. + +## Site-specific services and PRO labels + +Do not reproduce third-party user interfaces, help text, report formats, proprietary scores, datasets or site-specific PRO workflows. A PRO label, public availability or a claim of copyright alone does not establish exclusive legal rights over an underlying general calculation method. Nor does the absence of such a label grant reuse permission. + +Independent implementation of public methods sets the project boundary. It does not provide a blanket assurance against patent, trademark, contract, unfair-competition or other claims. No specific service's exclusive rights or a particular contribution's legal clearance is asserted here. [Article 101-2](https://www.law.go.kr/LSW/lsSideInfoP.do?docCls=jo&joBrNo=02&joNo=0101&lsiSeq=283335&urlMode=lsScJoRltInfoR) does not resolve those separate questions. + +## Provenance, review and project licenses + +Each contribution records its authorship, public-method references, included material's license and usage terms, and independent derivation as described in [CONTRIBUTING.md](../CONTRIBUTING.md). Maintainers review scope and unresolved rights before inclusion. Attribution does not replace permission or make excluded material acceptable. + +The package retains TableCloth's [AGPL license text](../LICENSE-AGPL) and [commercial-license contact notice](../LICENSE-COMMERCIAL) unchanged, including its original attribution. These files govern the project's licensing notices; they do not grant rights in third-party material. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/references/operations.md b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/references/operations.md new file mode 100644 index 00000000..e44f09af --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/references/operations.md @@ -0,0 +1,161 @@ +# Calculator operations and conventions + +This document specifies the original TableCloth calculator CLI as of October 25, 2026. The implementation uses Python 3.11 or later and only the standard library. It calculates from supplied values and fixed formulas; it does not retrieve rates, prices, exchange rates, credentials, or personal records. + +The Python source, tests, operation names, validation rules, and examples were written for this contribution. They do not copy another calculator service's source, instructions, screen text, or layout. The primary references linked below explain mathematical concepts, standard-library behavior, and measurement conventions. They do not endorse this implementation or establish compatibility with a particular bank, tax authority, medical service, or commercial calculator. + +## JSON transport and limits + +Run `scripts/calculate.py` with one UTF-8 JSON object on standard input. There are no command-line options. The script reads at most 65,537 bytes to enforce a 65,536-byte input limit. It writes one JSON line to standard output and returns exit status 0 on success or 2 on a rejected request. The implementation does not evaluate expressions, invoke subprocesses, access files, or make network requests. For isolated execution, use Python's `-I -S -B` switches; this also disables site initialization and bytecode writes. + +The following request applies one discount. + +```json +{"operation":"discount.single","price":"100","rate_percent":"20"} +``` + +A successful response has this shape. Decimal results are strings, including money values. Counts, calendar-day differences, and period indexes are JSON integers. + +```json +{"ok":true,"operation":"discount.single","result":{"original":"100.00","discount":"20.00","final":"80.00"},"assumptions":["Money uses 2 decimal places and ROUND_HALF_UP.","No tax or service-specific rules are applied.","Discounts multiply sequentially; only the final price is rounded. Discount = rounded original - rounded final."]} +``` + +A rejected request has this shape. Messages describe validation failures without returning the original payload or a traceback. + +```json +{"ok":false,"error":{"code":"invalid_input","message":"A 100 percent discount cannot be reversed."}} +``` + +Decimal inputs may be JSON numbers or decimal strings. Strings are preferable when the sending application otherwise uses binary floating point. Scientific notation is accepted within the same bounds. The parser rejects duplicate object keys, NaN, Infinity, booleans, null, unknown operations, missing required fields, and fields not listed for the selected operation. It does not accept arbitrary expressions or permissive date/time strings. + +- Maximum absolute numeric input: `1e15`. +- Maximum fractional precision: 12 places; at most 30 coefficient digits. +- Maximum absolute numeric result: `1e30`; decimal working precision: 50 digits. +- Money `places`: integer 0 through 6, default 2. Monetary input amounts round to these places before financial, price, discount, and split calculations. +- General numeric results: up to 12 fractional places, without trailing zeros. BMI uses exactly 2 places. +- Maximum contribution, compounding, or loan periods: 1,200. Loan schedules default to a summary; `schedule:"full"` returns at most 1,200 rows. +- Discount lists and split weights: 1 through 100 entries. Request objects have at most 20 fields, strings at most 128 characters, and arrays at most 1,200 elements. +- Maximum serialized response: 1 MiB. Out-of-range calculations fail rather than returning an infinity or a partial schedule. + +`invalid_json` covers malformed UTF-8/JSON and multiple top-level values. `invalid_input` covers schema and explicit domain checks. `out_of_range` covers numeric/date arithmetic that cannot fit the supported range. Decimal rounding follows Python's documented [decimal arithmetic and ROUND_HALF_UP](https://docs.python.org/3/library/decimal.html#rounding-modes). + +## Percentages, prices, and shared bills + +Every request also contains `operation`. Required and optional fields are case-sensitive. Percentages are supplied as `20` for 20%, not `0.20`. + +| Operation | Required fields | Optional fields | Result fields and convention | +| --- | --- | --- | --- | +| `percent.part` | `total`, `rate_percent` | None | `value = total * rate_percent / 100` | +| `percent.share` | `part`, `total` | None | `percent = part / total * 100`; total must not be zero; signed arithmetic | +| `percent.change` | `before`, `after` | None | `percent = (after - before) / before * 100`; before must be positive | +| `percent.adjust` | `value`, `rate_percent` | None | `value = input value * (1 + rate_percent / 100)`; a negative rate reduces the value | +| `percent.points` | `before_percent`, `after_percent` | None | `percentage_points = after_percent - before_percent`; no relative percentage calculation | +| `discount.single` | `price`, `rate_percent` | `places` | `original`, `discount`, `final`; rate 0 through 100 inclusive | +| `discount.sequential` | `price`, `rates_percent` array | `places` | Same result; multiply the remaining-price fractions in order, then round once | +| `discount.reverse` | `price`, `rate_percent` | `places` | Input price is the discounted price; rate must be less than 100; divide by the remaining-price fraction | +| `unit_price` | `total`, `quantity` | `per_quantity` (default 1), `places` | `unit_price = total / quantity * per_quantity`, plus `per_quantity`; quantities must be positive | +| `bill_split` | `total`, `weights` array | `places` | Rounded `total` and `shares` array; nonnegative weights with a positive sum | + +Prices, bill totals, and financial money amounts are nonnegative. Money operations do not identify a currency, exchange currencies, add tax, or infer a service charge. All comparable amounts therefore use the same caller-specified unit of money. + +Discounts derive the discount amount by subtracting the rounded final price from the rounded original price. This preserves `original = discount + final`, including half-minor-unit cases. Sequential discounts are not added: 20% followed by 10% leaves 72% of the original price. Commercial services may round at different stages; no service-specific convention is inferred. + +Bill splitting uses exact rational proportions of the supplied decimal weights. It allocates whole minor units first, then distributes remaining units to the largest fractional remainders. Ties follow input order. Shares remain nonnegative and sum exactly to the rounded total. Equal splitting uses identical positive weights, for example `["1","1","1"]`. + +## Fixed-rate financial formulas + +Rates are supplied by the user. `annual_rate_percent` is a **nominal annual rate**, from 0 through 1,000 inclusive. Negative interest rates, effective-rate/APR conversions, automatic day-count conventions, taxes, fees, grace periods, variable-rate forecasts, lending eligibility, legal limits, and investment recommendations are outside this interface. These are fixed mathematical scenarios rather than quotes or product-specific repayment statements. + +| Operation | Required fields | Optional fields | Results | +| --- | --- | --- | --- | +| `interest.simple` | `principal`, `annual_rate_percent`, `years` | `places` | `principal`, `interest`, `balance` | +| `interest.compound` | `principal`, `annual_rate_percent`, `years` | `compounds_per_year` (default 1), `places` | `principal`, `interest`, `balance` | +| `savings.recurring` | `initial`, `contribution`, `annual_rate_percent`, `periods`, `periods_per_year` | `timing` (`end` default or `begin`), `places` | `contribution`, `principal_total`, `interest`, `balance` | +| `savings.target` | `initial`, `target`, `annual_rate_percent`, `periods`, `periods_per_year` | `timing`, `places` | Same savings fields, plus `target` | +| `loan` | `principal`, `annual_rate_percent`, `periods`, `periods_per_year`, `method` | `schedule` (`summary` default or `full`), `places` | Repayment summary and optional schedule | + +`years` is from 0 through 100. For compounding, `compounds_per_year` is an integer from 1 through 366, and `years * compounds_per_year` must be a whole number no greater than 1,200. A fractional number of compounding periods is rejected. For savings and loans, `periods` is an integer from 1 through 1,200 and `periods_per_year` is an integer from 1 through 366. Contributions and interest occur at the same frequency. + +For annual rate `a = annual_rate_percent / 100`, principal `P`, and years `t`, simple interest is `P*a*t`. Compound balance is `P*(1+a/m)^(m*t)`, where `m` is the compounding frequency. The response rounds interest to the selected money precision and adds it to the rounded input principal. The [SEC Investor.gov compound-interest calculator](https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator) illustrates the separate roles of initial amount, contributions, time, annual rate, and compounding frequency; this CLI uses its own documented formulas and scope. + +For recurring savings, let `r = a / periods_per_year`, `n = periods`, and `F = (1+r)^n`. The end-of-period contribution factor is `(F-1)/r`, or `n` when `r=0`. Beginning-of-period contributions multiply that factor by `1+r`. Balance is `initial*F + contribution*factor`. Principal total is `initial + contribution*n`; interest is the rounded balance minus that principal total. This is period-based compounding, without per-period interest rounding. + +The target calculator solves `max(0, (target-initial*F)/factor)`. It rounds the required contribution **upward** to the selected minor unit, then recomputes the balance using that contribution. A contribution of zero means the projected initial balance already meets the supplied target. It does not imply the rate will be achieved in real use. + +The following request calculates the amount to contribute at the end of each of three zero-interest periods. + +```json +{"operation":"savings.target","initial":"0","target":"100","annual_rate_percent":"0","periods":3,"periods_per_year":12,"timing":"end"} +``` + +It returns contribution `33.34`, principal total/balance `100.02`, interest `0.00`, and target `100.00`. + +### Loan methods and rounding reconciliation + +`method` is one of `annuity`, `equal_principal`, or `bullet`. All methods charge the period's interest on its opening balance. The input principal must remain positive after rounding. The [CFPB explanation of amortization](https://www.consumerfinance.gov/ask-cfpb/what-is-amortization-and-how-could-it-affect-my-auto-loan-en-771/) explains the principal/interest split and amortization schedules; actual contracts can use different fees, dates, and rounding rules. + +- `annuity`: theoretical payment `P*r/(1-(1+r)^(-n))`; use `P/n` when `r=0`. +- `equal_principal`: theoretical principal repayment `P/n`, plus interest on the remaining balance. +- `bullet`: interest-only payments followed by principal plus interest in the final period. + +Regular amounts and period interest round with `ROUND_HALF_UP`. Principal repayments are capped at the remaining balance. The final period repays all remaining principal, so the last payment can differ from the regular payment. Tiny principals or very long terms can produce zero principal repayments in early periods after rounding; the last period still reconciles the balance. A rounded annuity payment below the period interest is rejected. + +The summary contains `method`, `principal`, `periods`, `periodic_rate_percent`, `total_interest`, `total_payment`, and `final_balance`. Annuities also include `regular_payment`. Full schedules add `schedule`, an array of `period`, `opening_balance`, `interest`, `principal`, `payment`, and `closing_balance`. Every schedule preserves the sum of principal repayments, the sum of payments/interest, nonnegative balances, and a final balance of zero. + +The following request exercises the final-payment adjustment. + +```json +{"operation":"loan","principal":"1000","annual_rate_percent":"0","periods":3,"periods_per_year":12,"method":"annuity","schedule":"full"} +``` + +Its payments are `333.33`, `333.33`, and `333.34`, with total principal/payment `1000.00` and final balance `0.00`. + +## Gregorian dates and fixed UTC offsets + +Dates use exactly `YYYY-MM-DD`, in years 0001 through 9999. The engine uses the proleptic Gregorian calendar. Reference dates come from the request; it does not read the current clock. It does not calculate public holidays, lunar calendars, legal ages, or jurisdiction-specific working days. Python documents the Gregorian date model and fixed-offset distinction in [datetime](https://docs.python.org/3/library/datetime.html). + +| Operation | Required fields | Optional fields | Result and convention | +| --- | --- | --- | --- | +| `date.diff` | `start`, `end` | `inclusion` (`elapsed` default or `both`) | Integer `days`; elapsed is signed end minus start; both includes both dates and requires end on or after start | +| `date.add` | `date` | Integer `years`, `months`, `days` (all default 0) | `date`; apply combined years/months once, clamp to the target month's last day, then add days | +| `date.age` | `birth_date`, `as_of` | `leap_day` (`feb28` default or `mar1`) | Completed integer `years`; birth must not be after as_of; February 29 convention applies in non-leap years | +| `date.weekday` | `date` | None | `iso_weekday` (Monday 1 through Sunday 7), English `weekday` | +| `time.convert` | `datetime`, `from_offset`, `to_offset` | None | ISO `source`, `converted`, and `utc` date/time strings | + +`date.add` accepts years from -9,999 through 9,999, months from -119,988 through 119,988, and days from -3,652,058 through 3,652,058; the final date must still be representable. Adding one month to January 31, 2025 yields February 28. Adding two months **from the original January 31 anchor** yields March 31. Repeatedly adding one month to the previous result would instead yield March 28; use the original anchor for each recurrence. Years and months combine before clamping: February 29, 2024 plus one year and one month yields March 29, 2025. + +Elapsed days between February 28 and March 1, 2024 are 2; including both dates yields 3. The same date yields elapsed 0 or inclusive 1. “One day after” and “the first day starting on” therefore use different conventions; choose explicitly. + +The datetime input uses exactly `YYYY-MM-DDTHH:MM[:SS[.ffffff]]`, without an embedded offset. Hours are 00 through 23 and minutes/seconds are 00 through 59. Signed fixed offsets use `+HH:MM` or `-HH:MM`, with absolute offset less than 24 hours. Fractional-hour offsets such as `+05:45` and `-03:30` are supported. Zero offsets normalize to `+00:00`. No leap seconds or `24:00` normalization is accepted. + +The following request crosses a year boundary. + +```json +{"operation":"time.convert","datetime":"2026-12-31T22:30","from_offset":"+00:00","to_offset":"+09:00"} +``` + +Its converted result is `2027-01-01T07:30:00+09:00`. A fixed offset does not change with the date. City names, `Asia/Seoul`, `America/New_York`, abbreviations such as `EST`, and automatic DST interpretation are unsupported. Python's [zoneinfo documentation](https://docs.python.org/3/library/zoneinfo.html#data-sources) describes the separate IANA database dependency and ambiguous local-time handling; this CLI does not load that database or infer either occurrence of a repeated wall-clock time. + +## Fixed unit conversion and BMI numbers + +`unit.convert` requires `value`, `from_unit`, and `to_unit`. Its result contains `value`, `unit`, and `family`. Unit identifiers are case-sensitive and both units must belong to the same family. + +| Family | Accepted identifiers | Base/convention | +| --- | --- | --- | +| Length | `mm`, `cm`, `m`, `km`, `in`, `ft`, `yd`, `mi` | Metres; international foot = 0.3048 m; mile = 1609.344 m | +| Mass | `mg`, `g`, `kg`, `oz`, `lb` | Kilograms; avoirdupois pound = 0.45359237 kg; ounce = 1/16 pound | +| Area | `sqm`, `sqft`, `pyeong` | Square metres; square foot = 0.09290304 sqm; pyeong = 400/121 sqm | +| Volume | `ml`, `l`, `m3` | Cubic metres; litre = 0.001 m3 | +| Speed | `m/s`, `km/h`, `mph` | Metres per second | +| Duration | `s`, `min`, `h`, `day`, `week` | Seconds; day = 86,400 seconds; week = 7 days | +| Temperature | `C`, `F`, `K` | Celsius, Fahrenheit, kelvin; affine conversion; values below absolute zero are rejected | + +The fixed international/avoirdupois unit conventions follow the [NIST SI conversion guide](https://www.nist.gov/pml/special-publication-811/nist-guide-si-appendix-b-conversion-factors). Pyeong is an explicit calculator convention rather than an SI unit or a property-valuation rule. No currency exchange, survey-foot alternative, calendar month/year duration, cooking-unit convention, or context-dependent conversion is inferred. Signed linear values are allowed; temperature must remain at or above absolute zero. + +`bmi` requires positive `weight_kg` and `height_cm`. It returns only `bmi`, calculated as `weight_kg / (height_cm/100)^2` and rounded to 2 decimal places. For example, 70 kg and 175 cm yields `22.86`. It accepts no age, sex, diagnostic thresholds, or growth-chart data. The [CDC BMI explanation](https://www.cdc.gov/bmi/about/index.html) supplies the formula and describes its interpretation limits; this operation does not produce a diagnosis, weight-status category, child percentile, or treatment advice. + +## Verification and unsupported requests + +From the skill directory, run `python -I -S -B -m unittest discover -s tests -v`. Set `PYTHONDONTWRITEBYTECODE=1` as well when invoking other Python tools. The tests cover ordinary outputs and numerical/date limits, schema errors, conservation of rounded totals, zero/tiny rates, schedule reconciliation, leap years, anchored month ends, inclusive dates, fractional offsets, date crossover, incompatible units, and BMI number-only output. They do not contact or validate live financial or medical services. + +Tax, insurance, legal/regulatory limits, live market or foreign-exchange data, holiday/lunar databases, location-based time-zone selection, investment advice, financial-product comparisons, PRO simulators, and service-specific calculations have no operations in this version. An unsupported request returns an error; it is not redirected to a network service or an arbitrary Python expression. diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/scripts/calculate.py b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/scripts/calculate.py new file mode 100644 index 00000000..7cbb8ad3 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/scripts/calculate.py @@ -0,0 +1,474 @@ +#!/usr/bin/env python3 +"""Deterministic, standard-library calculators. Read one JSON object from stdin.""" + +from __future__ import annotations + +import calendar +import datetime as dt +from decimal import Decimal, DecimalException, ROUND_CEILING, ROUND_HALF_UP, localcontext +from fractions import Fraction +import json +import re +import sys + +MAX_INPUT_BYTES = 65_536 +MAX_OUTPUT_BYTES = 1_048_576 +MAX_PERIODS = 1_200 +MAX_INPUT = Decimal("1e15") +MAX_RESULT = Decimal("1e30") +NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?", re.ASCII) +DATE = re.compile(r"\d{4}-\d{2}-\d{2}", re.ASCII) +DATETIME = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,6})?)?", re.ASCII) +OFFSET = re.compile(r"([+-])([01]\d|2[0-3]):([0-5]\d)", re.ASCII) + + +class CalculationError(ValueError): + """A bounded, user-readable validation error.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise CalculationError(message) + + +def number(value, name: str) -> Decimal: + require(not isinstance(value, bool), f"{name} must be a number, not a boolean.") + require(isinstance(value, (str, int, Decimal)), f"{name} must be a decimal number.") + text = str(value) + require(len(text) <= 80 and NUMBER.fullmatch(text) is not None, f"{name} must be a finite decimal number.") + result = Decimal(text) + require(result.is_finite(), f"{name} must be finite.") + require(-12 <= result.as_tuple().exponent <= 15 and len(result.as_tuple().digits) <= 30, + f"{name} exceeds the supported input precision (12 fractional digits).") + require(abs(result) <= MAX_INPUT, f"{name} exceeds the input magnitude limit of 1e15.") + return result + + +def integer(value, name: str, minimum: int, maximum: int) -> int: + result = number(value, name) + require(result == result.to_integral_value() and minimum <= result <= maximum, + f"{name} must be an integer from {minimum} to {maximum}.") + return int(result) + + +def numeric(data: dict, name: str, minimum=None, maximum=None) -> Decimal: + result = number(data[name], name) + require(minimum is None or result >= minimum, f"{name} is below its allowed minimum.") + require(maximum is None or result <= maximum, f"{name} exceeds its allowed maximum.") + return result + + +def schema(data: dict, required: tuple[str, ...], optional: tuple[str, ...] = ()) -> None: + missing = set(required) - data.keys() + extra = data.keys() - {"operation", *required, *optional} + require(not missing, "Missing required field(s): " + ", ".join(sorted(missing))) + require(not extra, "Unknown field(s): " + ", ".join(sorted(extra))) + + +def choice(data: dict, name: str, choices: tuple[str, ...], default: str) -> str: + value = data.get(name, default) + require(isinstance(value, str) and value in choices, f"{name} must be one of: {', '.join(choices)}.") + return value + + +def places_for(data: dict) -> int: + return integer(data.get("places", 2), "places", 0, 6) + + +def rounded(value: Decimal, places: int, mode=ROUND_HALF_UP) -> Decimal: + require(value.is_finite() and abs(value) <= MAX_RESULT, "Result exceeds the supported magnitude limit of 1e30.") + return value.quantize(Decimal(1).scaleb(-places), rounding=mode) + + +def display(value: Decimal) -> str: + value = rounded(value, 12) + if not value: + return "0" + return format(value, "f").rstrip("0").rstrip(".") + + +def money(value: Decimal, places: int) -> str: + value = rounded(value, places) + return format(abs(value) if not value else value, f".{places}f") + + +def amount(data: dict, key: str, places: int) -> Decimal: + return rounded(numeric(data, key, Decimal(0)), places) + + +def money_assumptions(places: int) -> list[str]: + return [f"Money uses {places} decimal places and ROUND_HALF_UP.", + "No tax or service-specific rules are applied."] + + +def percentages(data: dict, operation: str): + if operation == "percent.part": + schema(data, ("total", "rate_percent")) + value = numeric(data, "total") * numeric(data, "rate_percent") / 100 + return {"value": display(value)}, ["rate_percent is a percentage, not a decimal fraction."] + if operation == "percent.share": + schema(data, ("part", "total")) + total = numeric(data, "total") + require(total != 0, "total must not be zero.") + return {"percent": display(numeric(data, "part") / total * 100)}, ["Signed part / total * 100."] + if operation == "percent.change": + schema(data, ("before", "after")) + before = numeric(data, "before") + require(before > 0, "before must be positive; zero and negative baselines are unsupported.") + return {"percent": display((numeric(data, "after") - before) / before * 100)}, ["The baseline is positive."] + if operation == "percent.adjust": + schema(data, ("value", "rate_percent")) + return {"value": display(numeric(data, "value") * (1 + numeric(data, "rate_percent") / 100))}, ["Negative rates reduce the value."] + schema(data, ("before_percent", "after_percent")) + return {"percentage_points": display(numeric(data, "after_percent") - numeric(data, "before_percent"))}, ["This is a percentage-point difference, not a relative percentage change."] + + +def discounts(data: dict, operation: str): + if operation == "discount.sequential": + schema(data, ("price", "rates_percent"), ("places",)) + rates = data["rates_percent"] + require(isinstance(rates, list) and 1 <= len(rates) <= 100, "rates_percent must contain 1 to 100 rates.") + rates = [number(item, "rates_percent") for item in rates] + else: + schema(data, ("price", "rate_percent"), ("places",)) + rates = [numeric(data, "rate_percent")] + require(all(0 <= rate <= 100 for rate in rates), "Discount rates must be from 0 to 100 percent.") + places = places_for(data) + price = amount(data, "price", places) + if operation == "discount.reverse": + require(rates[0] < 100, "A 100 percent discount cannot be reversed.") + final = price + original = rounded(final / (1 - rates[0] / 100), places) + else: + original = price + final = price + for rate in rates: + final *= 1 - rate / 100 + final = rounded(final, places) + assumptions = money_assumptions(places) + assumptions.append("Discounts multiply sequentially; only the final price is rounded. Discount = rounded original - rounded final.") + return {"original": money(original, places), "discount": money(original - final, places), "final": money(final, places)}, assumptions + + +def unit_price(data: dict): + schema(data, ("total", "quantity"), ("per_quantity", "places")) + places = places_for(data) + quantity = numeric(data, "quantity") + per = number(data.get("per_quantity", 1), "per_quantity") + require(quantity > 0 and per > 0, "quantity and per_quantity must be positive.") + value = amount(data, "total", places) / quantity * per + return {"unit_price": money(value, places), "per_quantity": display(per)}, money_assumptions(places) + + +def bill_split(data: dict): + schema(data, ("total", "weights"), ("places",)) + weights = data["weights"] + require(isinstance(weights, list) and 1 <= len(weights) <= 100, "weights must contain 1 to 100 values.") + weights = [number(value, "weights") for value in weights] + require(all(value >= 0 for value in weights) and sum(weights) > 0, "weights must be nonnegative with a positive sum.") + places = places_for(data) + total = amount(data, "total", places) + quantum = Decimal(1).scaleb(-places) + units = int(total / quantum) + exact_weights = [Fraction(value) for value in weights] + exact = [units * value / sum(exact_weights) for value in exact_weights] + allocated = [value.numerator // value.denominator for value in exact] + order = sorted(range(len(weights)), key=lambda i: (-(exact[i] - allocated[i]), i)) + for index in order[:units - sum(allocated)]: + allocated[index] += 1 + return {"total": money(total, places), "shares": [money(Decimal(value) * quantum, places) for value in allocated]}, money_assumptions(places) + ["Largest remainders receive leftover minor units; ties follow input order."] + + +def interest(data: dict, operation: str): + optional = ("places", "compounds_per_year") if operation == "interest.compound" else ("places",) + schema(data, ("principal", "annual_rate_percent", "years"), optional) + places = places_for(data) + principal = amount(data, "principal", places) + rate = numeric(data, "annual_rate_percent", Decimal(0), Decimal(1000)) / 100 + years = numeric(data, "years", Decimal(0), Decimal(100)) + assumptions = money_assumptions(places) + if operation == "interest.simple": + earned = principal * rate * years + assumptions.append("Simple interest uses user-supplied years; no bank day-count convention is inferred.") + else: + frequency = integer(data.get("compounds_per_year", 1), "compounds_per_year", 1, 366) + count = frequency * years + require(count == count.to_integral_value() and count <= MAX_PERIODS, "years * compounds_per_year must be a whole number from 0 to 1200.") + earned = principal * ((1 + rate / frequency) ** int(count) - 1) + assumptions.append("The annual rate is nominal; periodic rate = annual rate / compounds_per_year.") + earned = rounded(earned, places) + return {"principal": money(principal, places), "interest": money(earned, places), "balance": money(principal + earned, places)}, assumptions + + +def savings(data: dict, operation: str): + extra = "contribution" if operation == "savings.recurring" else "target" + schema(data, ("initial", extra, "annual_rate_percent", "periods", "periods_per_year"), ("timing", "places")) + places = places_for(data) + initial = amount(data, "initial", places) + rate = numeric(data, "annual_rate_percent", Decimal(0), Decimal(1000)) / 100 + count = integer(data["periods"], "periods", 1, MAX_PERIODS) + frequency = integer(data["periods_per_year"], "periods_per_year", 1, 366) + timing = choice(data, "timing", ("end", "begin"), "end") + r = rate / frequency + factor = (1 + r) ** count + annuity = Decimal(count) if r == 0 else (factor - 1) / r + if timing == "begin": + annuity *= 1 + r + assumptions = money_assumptions(places) + ["The annual rate is nominal; interest compounds once per contribution period.", f"Contributions occur at the {timing} of each period; rates stay fixed."] + if operation == "savings.target": + target = amount(data, "target", places) + contribution = rounded(max(Decimal(0), (target - initial * factor) / annuity), places, ROUND_CEILING) + assumptions.append("Required contribution rounds upward to the selected minor unit; zero is returned if the initial balance already meets the target.") + else: + contribution = amount(data, "contribution", places) + balance = rounded(initial * factor + contribution * annuity, places) + principal = initial + contribution * count + result = {"contribution": money(contribution, places), "principal_total": money(principal, places), + "interest": money(balance - principal, places), "balance": money(balance, places)} + if operation == "savings.target": + result["target"] = money(target, places) + return result, assumptions + + +def loan(data: dict): + schema(data, ("principal", "annual_rate_percent", "periods", "periods_per_year", "method"), ("schedule", "places")) + places = places_for(data) + principal = amount(data, "principal", places) + require(principal > 0, "principal must remain positive after money rounding.") + count = integer(data["periods"], "periods", 1, MAX_PERIODS) + frequency = integer(data["periods_per_year"], "periods_per_year", 1, 366) + rate = numeric(data, "annual_rate_percent", Decimal(0), Decimal(1000)) / 100 / frequency + method = choice(data, "method", ("annuity", "equal_principal", "bullet"), "annuity") + schedule = choice(data, "schedule", ("summary", "full"), "summary") + regular = rounded(principal / count if rate == 0 else principal * rate / (1 - (1 + rate) ** (-count)), places) + fixed_principal = rounded(principal / count, places) + balance, total_interest, total_payment = principal, Decimal(0), Decimal(0) + rows = [] + for period in range(1, count + 1): + opening = balance + earned = rounded(balance * rate, places) + if period == count: + repaid = balance + elif method == "bullet": + repaid = Decimal(0) + elif method == "equal_principal": + repaid = min(balance, fixed_principal) + else: + require(regular >= earned, "The rounded regular payment does not cover interest.") + repaid = min(balance, regular - earned) + payment = repaid + earned + balance -= repaid + total_interest += earned + total_payment += payment + if schedule == "full": + rows.append({"period": period, "opening_balance": money(opening, places), "interest": money(earned, places), + "principal": money(repaid, places), "payment": money(payment, places), "closing_balance": money(balance, places)}) + result = {"method": method, "principal": money(principal, places), "periods": count, + "periodic_rate_percent": display(rate * 100), "total_interest": money(total_interest, places), + "total_payment": money(total_payment, places), "final_balance": money(balance, places)} + if method == "annuity": + result["regular_payment"] = money(regular, places) + if schedule == "full": + result["schedule"] = rows + return result, money_assumptions(places) + ["Fixed nominal annual rate divided by periods_per_year; equal-length periods with no fees or grace period.", "Interest and regular amounts round each period; the last principal payment reconciles the remaining balance."] + + +def parse_date(value, name: str) -> dt.date: + require(isinstance(value, str) and DATE.fullmatch(value) is not None, f"{name} must use YYYY-MM-DD.") + try: + return dt.date.fromisoformat(value) + except ValueError: + raise CalculationError(f"{name} is not a valid Gregorian date.") from None + + +def shifted_months(value: dt.date, months: int) -> dt.date: + index = (value.year - 1) * 12 + value.month - 1 + months + require(0 <= index < 9999 * 12, "Date result is outside years 0001 through 9999.") + year, month = divmod(index, 12) + year, month = year + 1, month + 1 + return dt.date(year, month, min(value.day, calendar.monthrange(year, month)[1])) + + +def dates(data: dict, operation: str): + assumptions = ["Proleptic Gregorian calendar; no holidays, lunar dates, or legal age rules."] + if operation == "date.diff": + schema(data, ("start", "end"), ("inclusion",)) + start, end = parse_date(data["start"], "start"), parse_date(data["end"], "end") + mode = choice(data, "inclusion", ("elapsed", "both"), "elapsed") + require(mode != "both" or end >= start, "both requires end on or after start.") + return {"days": (end - start).days + (1 if mode == "both" else 0)}, assumptions + [f"Counting mode: {mode}."] + if operation == "date.add": + schema(data, ("date",), ("years", "months", "days")) + value = parse_date(data["date"], "date") + years = integer(data.get("years", 0), "years", -9999, 9999) + months = integer(data.get("months", 0), "months", -119988, 119988) + days = integer(data.get("days", 0), "days", -3652058, 3652058) + value = shifted_months(value, years * 12 + months) + dt.timedelta(days=days) + return {"date": value.isoformat()}, assumptions + ["Years and months apply together to the original date, clamped to the target month's last day; days apply afterward. Repeated schedules should always use the original anchor."] + if operation == "date.age": + schema(data, ("birth_date", "as_of"), ("leap_day",)) + birth, as_of = parse_date(data["birth_date"], "birth_date"), parse_date(data["as_of"], "as_of") + require(birth <= as_of, "birth_date must not be after as_of.") + leap_day = choice(data, "leap_day", ("feb28", "mar1"), "feb28") + anniversary = shifted_months(birth, (as_of.year - birth.year) * 12) + if birth.month == 2 and birth.day == 29 and not calendar.isleap(as_of.year) and leap_day == "mar1": + anniversary = dt.date(as_of.year, 3, 1) + return {"years": as_of.year - birth.year - int(as_of < anniversary)}, assumptions + [f"A February 29 birthday uses {leap_day} in non-leap years; this is a calculator convention."] + schema(data, ("date",)) + value = parse_date(data["date"], "date") + return {"iso_weekday": value.isoweekday(), "weekday": ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")[value.weekday()]}, assumptions + + +def offset(value, name: str) -> dt.timezone: + match = OFFSET.fullmatch(value) if isinstance(value, str) else None + require(match is not None, f"{name} must use a signed fixed UTC offset +/-HH:MM below 24 hours.") + sign, hours, minutes = match.groups() + total = (int(hours) * 60 + int(minutes)) * (-1 if sign == "-" else 1) + return dt.timezone(dt.timedelta(minutes=total)) + + +def time_conversion(data: dict): + schema(data, ("datetime", "from_offset", "to_offset")) + value = data["datetime"] + require(isinstance(value, str) and DATETIME.fullmatch(value) is not None, "datetime must use YYYY-MM-DDTHH:MM[:SS[.ffffff]] without an offset.") + hour, minute = int(value[11:13]), int(value[14:16]) + second = int(value[17:19]) if len(value) >= 19 else 0 + require(hour < 24 and minute < 60 and second < 60, "Hours must be 00 through 23; minutes and seconds must be 00 through 59.") + try: + microsecond = int(value[20:].ljust(6, "0")) if len(value) > 19 else 0 + value = dt.datetime(int(value[:4]), int(value[5:7]), int(value[8:10]), hour, minute, second, microsecond) + except ValueError: + raise CalculationError("datetime is not a valid Gregorian date and time.") from None + source = value.replace(tzinfo=offset(data["from_offset"], "from_offset")) + converted = source.astimezone(offset(data["to_offset"], "to_offset")) + return {"source": source.isoformat(), "converted": converted.isoformat(), "utc": source.astimezone(dt.timezone.utc).isoformat()}, ["Offsets are fixed; no city names, IANA time zones, DST, or leap seconds are inferred."] + + +def units(data: dict): + schema(data, ("value", "from_unit", "to_unit")) + # Each factor converts to the family's base unit. Temperature is affine. + groups = { + "length": {"mm": Decimal(".001"), "cm": Decimal(".01"), "m": Decimal(1), "km": Decimal(1000), "in": Decimal(".0254"), "ft": Decimal(".3048"), "yd": Decimal(".9144"), "mi": Decimal("1609.344")}, + "mass": {"mg": Decimal(".000001"), "g": Decimal(".001"), "kg": Decimal(1), "oz": Decimal(".028349523125"), "lb": Decimal(".45359237")}, + "area": {"sqm": Decimal(1), "sqft": Decimal(".09290304"), "pyeong": Decimal(400) / 121}, + "volume": {"ml": Decimal(".000001"), "l": Decimal(".001"), "m3": Decimal(1)}, + "speed": {"m/s": Decimal(1), "km/h": Decimal(5) / 18, "mph": Decimal(".44704")}, + "duration": {"s": Decimal(1), "min": Decimal(60), "h": Decimal(3600), "day": Decimal(86400), "week": Decimal(604800)}, + "temperature": {"C": Decimal(1), "F": Decimal(1), "K": Decimal(1)}, + } + source, target = data["from_unit"], data["to_unit"] + require(isinstance(source, str) and isinstance(target, str), "Unit identifiers must be strings.") + family = next((name for name, members in groups.items() if source in members), None) + require(family is not None and target in groups[family], "Units must be known identifiers in the same family.") + value = numeric(data, "value") + if family == "temperature": + celsius = (value - 32) * 5 / 9 if source == "F" else value - Decimal("273.15") if source == "K" else value + require(celsius >= Decimal("-273.15"), "Temperature cannot be below absolute zero.") + result = celsius * 9 / 5 + 32 if target == "F" else celsius + Decimal("273.15") if target == "K" else celsius + else: + result = value * groups[family][source] / groups[family][target] + return {"value": display(result), "unit": target, "family": family}, ["Fixed conversion factors only; pyeong = 400/121 square metres. Duration days are exactly 24 hours; months and years are not duration units."] + + +def bmi(data: dict): + schema(data, ("weight_kg", "height_cm")) + weight, height = numeric(data, "weight_kg"), numeric(data, "height_cm") + require(weight > 0 and height > 0, "weight_kg and height_cm must be positive.") + result = weight / (height / 100) ** 2 + return {"bmi": money(result, 2)}, ["BMI = weight in kg / height in metres squared, rounded to 2 decimal places.", "Number only: no diagnosis, weight-status classification, child percentile, or treatment advice."] + + +def calculate(request: dict) -> dict: + require(isinstance(request, dict), "The input must be one JSON object.") + operation = request.get("operation") + require(isinstance(operation, str), "operation must be a string.") + with localcontext() as context: + context.prec, context.Emax, context.Emin = 50, 99, -99 + if operation in ("percent.part", "percent.share", "percent.change", "percent.adjust", "percent.points"): + result, assumptions = percentages(request, operation) + elif operation in ("discount.single", "discount.sequential", "discount.reverse"): + result, assumptions = discounts(request, operation) + elif operation == "unit_price": + result, assumptions = unit_price(request) + elif operation == "bill_split": + result, assumptions = bill_split(request) + elif operation in ("interest.simple", "interest.compound"): + result, assumptions = interest(request, operation) + elif operation in ("savings.recurring", "savings.target"): + result, assumptions = savings(request, operation) + elif operation == "loan": + result, assumptions = loan(request) + elif operation in ("date.diff", "date.add", "date.age", "date.weekday"): + result, assumptions = dates(request, operation) + elif operation == "time.convert": + result, assumptions = time_conversion(request) + elif operation == "unit.convert": + result, assumptions = units(request) + elif operation == "bmi": + result, assumptions = bmi(request) + else: + raise CalculationError("Unknown operation.") + return {"ok": True, "operation": operation, "result": result, "assumptions": assumptions} + + +def json_number(text: str) -> Decimal: + require(len(text) <= 80, "A JSON number exceeds the supported input length.") + return Decimal(text) + + +def reject_constant(_value): + raise CalculationError("NaN and Infinity are not supported.") + + +def unique_object(pairs): + result = {} + for key, value in pairs: + require(key not in result, "Duplicate JSON object keys are not supported.") + result[key] = value + return result + + +def check_structure(value, depth=0): + require(depth <= 3, "Nested inputs are not supported.") + require(not isinstance(value, bool) and value is not None, "Boolean and null inputs are not supported.") + if isinstance(value, dict): + require(len(value) <= 20, "Too many input fields.") + for key, item in value.items(): + require(len(key) <= 64, "An input field name is too long.") + check_structure(item, depth + 1) + elif isinstance(value, list): + require(len(value) <= MAX_PERIODS, "Input array is too long.") + for item in value: + check_structure(item, depth + 1) + elif isinstance(value, str): + require(len(value) <= 128, "An input string is too long.") + + +def process_payload(raw: bytes) -> tuple[dict, int]: + try: + require(len(raw) <= MAX_INPUT_BYTES, "Input exceeds 65536 bytes.") + request = json.loads(raw.decode("utf-8"), parse_int=json_number, parse_float=json_number, + parse_constant=reject_constant, object_pairs_hook=unique_object) + check_structure(request) + response = calculate(request) + require(len(json.dumps(response).encode("utf-8")) <= MAX_OUTPUT_BYTES, "Output exceeds the supported size.") + return response, 0 + except CalculationError as error: + return {"ok": False, "error": {"code": "invalid_input", "message": str(error)}}, 2 + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError): + return {"ok": False, "error": {"code": "invalid_json", "message": "Input must be one valid UTF-8 JSON object."}}, 2 + except (DecimalException, OverflowError, ValueError): + return {"ok": False, "error": {"code": "out_of_range", "message": "The calculation exceeds the supported numeric or date range."}}, 2 + + +def main() -> int: + if len(sys.argv) != 1: + response, status = {"ok": False, "error": {"code": "invalid_input", "message": "Pass one JSON object through stdin; command-line arguments are unsupported."}}, 2 + else: + response, status = process_payload(sys.stdin.buffer.read(MAX_INPUT_BYTES + 1)) + sys.stdout.write(json.dumps(response, ensure_ascii=True, allow_nan=False, separators=(",", ":")) + "\n") + return status + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/appcontainer-skill-poc/skills/tablecloth-calculators/tests/test_calculate.py b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/tests/test_calculate.py new file mode 100644 index 00000000..a86b5d85 --- /dev/null +++ b/tools/appcontainer-skill-poc/skills/tablecloth-calculators/tests/test_calculate.py @@ -0,0 +1,284 @@ +"""Behavior tests; no services, files, or child processes are used by calculators.""" + +import io +import json +from decimal import Decimal +from pathlib import Path +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import calculate as engine + + +class CalculatorTests(unittest.TestCase): + def request(self, operation, **fields): + result, status = engine.process_payload(json.dumps({"operation": operation, **fields}).encode()) + self.assertEqual(status, 0, result) + self.assertTrue(result["ok"]) + self.assertTrue(result["assumptions"]) + return result["result"] + + def invalid(self, operation, **fields): + result, status = engine.process_payload(json.dumps({"operation": operation, **fields}).encode()) + self.assertEqual(status, 2, result) + self.assertFalse(result["ok"]) + self.assertIn(result["error"]["code"], ("invalid_input", "invalid_json", "out_of_range")) + self.assertTrue(result["error"]["message"]) + return result + + def test_percentages_and_points_are_distinct(self): + self.assertEqual(self.request("percent.part", total="200", rate_percent="12.5"), {"value": "25"}) + self.assertEqual(self.request("percent.share", part="1", total="4"), {"percent": "25"}) + self.assertEqual(self.request("percent.change", before="100", after="90"), {"percent": "-10"}) + self.assertEqual(self.request("percent.adjust", value="100", rate_percent="-20"), {"value": "80"}) + self.assertEqual(self.request("percent.points", before_percent="5", after_percent="6"), {"percentage_points": "1"}) + self.assertEqual(self.request("percent.change", before="5", after="6"), {"percent": "20"}) + + def test_percent_baseline_and_required_fields(self): + for before in ("0", "-100"): + self.invalid("percent.change", before=before, after="10") + self.invalid("percent.share", part="1", total="0") + self.invalid("percent.part", total="100") + self.invalid("percent.part", total="100", rate_percent="1", extra="ignored") + + def test_number_input_forms_and_precision(self): + self.assertEqual(self.request("percent.part", total=200, rate_percent=12.5), {"value": "25"}) + self.assertEqual(self.request("percent.part", total="1e3", rate_percent="1e-1"), {"value": "1"}) + self.assertEqual(self.request("percent.adjust", value="0", rate_percent="-100"), {"value": "0"}) + for value in (True, False, None, "NaN", "Infinity", "-Infinity", "1e100000", "1e-100000", "1.0000000000001", "1e16", [], {}, " 1", "1,000", "2+3", "__import__('os')"): + with self.subTest(value=value): + self.invalid("percent.part", total=value, rate_percent="5") + + def test_discounts_and_reverse(self): + self.assertEqual(self.request("discount.single", price="100", rate_percent="20"), + {"original": "100.00", "discount": "20.00", "final": "80.00"}) + self.assertEqual(self.request("discount.sequential", price="100", rates_percent=["20", "10"])["final"], "72.00") + self.assertEqual(self.request("discount.reverse", price="80", rate_percent="20")["original"], "100.00") + self.assertEqual(self.request("discount.single", price="100", rate_percent="100")["final"], "0.00") + self.invalid("discount.reverse", price="0", rate_percent="100") + self.invalid("discount.single", price="100", rate_percent="-1") + self.invalid("discount.single", price="100", rate_percent="101") + self.invalid("discount.sequential", price="100", rates_percent=[]) + self.invalid("discount.sequential", price="100", rates_percent=["1"] * 101) + + def test_discount_rounding_reconciles_total(self): + result = self.request("discount.single", price="1", rate_percent="99.5") + self.assertEqual(result["final"], "0.01") + self.assertEqual(result["discount"], "0.99") + self.assertEqual(Decimal(result["original"]), Decimal(result["discount"]) + Decimal(result["final"])) + self.assertEqual(self.request("discount.single", price="100", rate_percent="12.5", places=0)["final"], "88") + + def test_unit_price(self): + self.assertEqual(self.request("unit_price", total="12", quantity="500", per_quantity="100")["unit_price"], "2.40") + self.invalid("unit_price", total="10", quantity="0") + self.invalid("unit_price", total="-10", quantity="2") + self.invalid("unit_price", total="10", quantity="2", per_quantity="-1") + + def test_bill_split_conserves_minor_units_and_tie_order(self): + self.assertEqual(self.request("bill_split", total="100", weights=["1", "2"])["shares"], ["33.33", "66.67"]) + self.assertEqual(self.request("bill_split", total="0.01", weights=["1", "1", "1"])["shares"], ["0.01", "0.00", "0.00"]) + self.assertEqual(self.request("bill_split", total="1", weights=["0", "1", "2"], places=0)["shares"], ["0", "0", "1"]) + for weights in (["0", "0"], ["-1", "2"], [], ["1"] * 101): + self.invalid("bill_split", total="100", weights=weights) + + def test_bill_split_large_and_fractional_weights(self): + for total, weights in [("1000000000000000", ["0.000000000001", "999999999999999", "1"]), + ("0.07", ["1", "1", "1", "1", "1", "1"]), + ("1234.567891", ["0.001", "0.002", "0.003"])]: + with self.subTest(total=total): + result = self.request("bill_split", total=total, weights=weights, places=6) + self.assertEqual(sum(map(Decimal, result["shares"])), Decimal(result["total"])) + self.assertTrue(all(Decimal(value) >= 0 for value in result["shares"])) + + def test_simple_and_compound_interest(self): + self.assertEqual(self.request("interest.simple", principal="1000000", annual_rate_percent="5", years="2")["interest"], "100000.00") + self.assertEqual(self.request("interest.compound", principal="1000000", annual_rate_percent="10", years="2")["balance"], "1210000.00") + self.assertEqual(self.request("interest.compound", principal="100", annual_rate_percent="0", years="2", compounds_per_year=12)["balance"], "100.00") + self.assertEqual(self.request("interest.compound", principal="100", annual_rate_percent="10", years="0")["balance"], "100.00") + self.assertEqual(self.request("interest.simple", principal="100", annual_rate_percent="10", years="0.5")["interest"], "5.00") + self.invalid("interest.compound", principal="100", annual_rate_percent="10", years="0.1", compounds_per_year=12) + self.invalid("interest.compound", principal="100", annual_rate_percent="10", years="100", compounds_per_year=365) + self.invalid("interest.simple", principal="100", annual_rate_percent="-1", years="2") + + def test_recurring_savings_timing_and_zero_rate(self): + common = dict(initial="100", contribution="10", annual_rate_percent="10", periods=2, periods_per_year=1) + end = self.request("savings.recurring", **common) + begin = self.request("savings.recurring", **common, timing="begin") + self.assertEqual(end["balance"], "142.00") + self.assertEqual(begin["balance"], "144.10") + self.assertEqual(end["principal_total"], "120.00") + self.assertEqual(end["interest"], "22.00") + common["annual_rate_percent"] = "0" + self.assertEqual(self.request("savings.recurring", **common)["balance"], "120.00") + + def test_target_contribution_rounds_up_and_never_negative(self): + common = dict(initial="0", target="100", annual_rate_percent="0", periods=3, periods_per_year=12) + result = self.request("savings.target", **common) + self.assertEqual(result["contribution"], "33.34") + self.assertEqual(result["balance"], "100.02") + common["initial"] = "200" + self.assertEqual(self.request("savings.target", **common)["contribution"], "0.00") + common["periods"] = 0 + self.invalid("savings.target", **common) + + def assert_schedule(self, result): + rows = result["schedule"] + self.assertEqual(len(rows), result["periods"]) + self.assertEqual(sum(Decimal(row["principal"]) for row in rows), Decimal(result["principal"])) + self.assertEqual(sum(Decimal(row["interest"]) for row in rows), Decimal(result["total_interest"])) + self.assertEqual(sum(Decimal(row["payment"]) for row in rows), Decimal(result["total_payment"])) + self.assertEqual(Decimal(result["total_payment"]), Decimal(result["principal"]) + Decimal(result["total_interest"])) + self.assertEqual(Decimal(result["final_balance"]), 0) + for i, row in enumerate(rows): + self.assertEqual(row["period"], i + 1) + self.assertEqual(Decimal(row["opening_balance"]) - Decimal(row["principal"]), Decimal(row["closing_balance"])) + self.assertEqual(Decimal(row["principal"]) + Decimal(row["interest"]), Decimal(row["payment"])) + self.assertTrue(all(Decimal(row[key]) >= 0 for key in ("principal", "interest", "payment", "closing_balance"))) + if i: + self.assertEqual(rows[i - 1]["closing_balance"], row["opening_balance"]) + + def test_three_loan_methods_reconcile_and_have_correct_structure(self): + common = dict(principal="1200", annual_rate_percent="12", periods=3, periods_per_year=12, schedule="full") + for method in ("annuity", "equal_principal", "bullet"): + with self.subTest(method=method): + result = self.request("loan", **common, method=method) + self.assert_schedule(result) + if method == "equal_principal": + self.assertEqual([row["principal"] for row in result["schedule"]], ["400.00"] * 3) + self.assertEqual([row["interest"] for row in result["schedule"]], ["12.00", "8.00", "4.00"]) + if method == "bullet": + self.assertEqual([row["principal"] for row in result["schedule"]], ["0.00", "0.00", "1200.00"]) + self.assertEqual(result["total_interest"], "36.00") + + def test_zero_rate_loan_last_payment_reconciles(self): + result = self.request("loan", principal="1000", annual_rate_percent="0", periods=3, periods_per_year=12, method="annuity", schedule="full") + self.assert_schedule(result) + self.assertEqual([row["payment"] for row in result["schedule"]], ["333.33", "333.33", "333.34"]) + + def test_loan_one_period_tiny_rate_and_term_bounds(self): + for principal, rate, periods in (("0.01", "0", 1200), ("100", "0.000000000001", 12), ("1000", "1000", 1)): + result = self.request("loan", principal=principal, annual_rate_percent=rate, periods=periods, periods_per_year=12, method="annuity", schedule="full") + self.assert_schedule(result) + for periods in (0, -1, 1201, "1.5", True): + self.invalid("loan", principal="100", annual_rate_percent="5", periods=periods, periods_per_year=12, method="annuity") + self.invalid("loan", principal="0.001", annual_rate_percent="5", periods=12, periods_per_year=12, method="annuity") + self.invalid("loan", principal="100", annual_rate_percent="5", periods=12, periods_per_year=0, method="annuity") + + def test_loan_summary_has_no_schedule(self): + result = self.request("loan", principal="1200", annual_rate_percent="0", periods=12, periods_per_year=12, method="annuity") + self.assertNotIn("schedule", result) + self.assertEqual(result["regular_payment"], "100.00") + + def test_date_difference_and_inclusion(self): + common = dict(start="2024-02-28", end="2024-03-01") + self.assertEqual(self.request("date.diff", **common)["days"], 2) + self.assertEqual(self.request("date.diff", **common, inclusion="both")["days"], 3) + self.assertEqual(self.request("date.diff", start="2025-01-01", end="2025-01-01")["days"], 0) + self.assertEqual(self.request("date.diff", start="2025-01-01", end="2025-01-01", inclusion="both")["days"], 1) + self.assertEqual(self.request("date.diff", start="2025-01-02", end="2025-01-01")["days"], -1) + self.invalid("date.diff", start="2025-01-02", end="2025-01-01", inclusion="both") + + def test_month_end_uses_original_anchor_and_combined_year_month_shift(self): + self.assertEqual(self.request("date.add", date="2025-01-31", months=1)["date"], "2025-02-28") + self.assertEqual(self.request("date.add", date="2025-01-31", months=2)["date"], "2025-03-31") + self.assertEqual(self.request("date.add", date="2024-02-29", years=1)["date"], "2025-02-28") + self.assertEqual(self.request("date.add", date="2024-02-29", years=1, months=1)["date"], "2025-03-29") + self.assertEqual(self.request("date.add", date="2025-03-31", months=-1, days=1)["date"], "2025-03-01") + + def test_age_and_weekday(self): + self.assertEqual(self.request("date.age", birth_date="2000-02-29", as_of="2025-02-28")["years"], 25) + self.assertEqual(self.request("date.age", birth_date="2000-02-29", as_of="2025-02-28", leap_day="mar1")["years"], 24) + self.assertEqual(self.request("date.age", birth_date="2025-02-28", as_of="2025-02-28")["years"], 0) + self.assertEqual(self.request("date.weekday", date="2024-02-29"), {"iso_weekday": 4, "weekday": "Thursday"}) + self.invalid("date.age", birth_date="2026-01-01", as_of="2025-01-01") + + def test_invalid_dates_and_limits(self): + for value in ("1900-02-29", "2025-02-29", "2025-13-01", "0000-01-01", "20250101", "2025-W01-1", "2025-1-1"): + self.invalid("date.weekday", date=value) + self.assertEqual(self.request("date.weekday", date="2000-02-29")["iso_weekday"], 2) + self.invalid("date.add", date="9999-12-31", days=1) + self.invalid("date.add", date="0001-01-01", months=-1) + self.invalid("date.add", date="2025-01-01", years=10000) + self.invalid("date.add", date="2025-01-01", days="1.2") + + def test_fixed_offset_fractional_and_date_crossovers(self): + result = self.request("time.convert", datetime="2026-12-31T22:30", from_offset="+00:00", to_offset="+09:00") + self.assertEqual(result["converted"], "2027-01-01T07:30:00+09:00") + self.assertEqual(self.request("time.convert", datetime="2026-10-25T12:00", from_offset="+05:45", to_offset="+09:00")["converted"], "2026-10-25T15:15:00+09:00") + self.assertEqual(self.request("time.convert", datetime="2026-01-01T01:00", from_offset="+09:00", to_offset="-03:30")["converted"], "2025-12-31T12:30:00-03:30") + + def test_same_wall_clock_can_have_two_explicit_offsets(self): + first = self.request("time.convert", datetime="2020-11-01T01:00", from_offset="-07:00", to_offset="+00:00") + second = self.request("time.convert", datetime="2020-11-01T01:00", from_offset="-08:00", to_offset="+00:00") + self.assertEqual(first["converted"], "2020-11-01T08:00:00+00:00") + self.assertEqual(second["converted"], "2020-11-01T09:00:00+00:00") + + def test_named_zones_invalid_offsets_and_leap_seconds_are_rejected(self): + for offset in ("America/New_York", "UTC", "+24:00", "+09:60", "+9:00", 9): + self.invalid("time.convert", datetime="2025-01-01T00:00", from_offset=offset, to_offset="+00:00") + for value in ("2025-01-01T24:00", "2025-01-01T00:00:60", "2025-01-01 00:00", "2025-01-01T00:00Z"): + self.invalid("time.convert", datetime=value, from_offset="+00:00", to_offset="+09:00") + self.invalid("time.convert", datetime="0001-01-01T00:00", from_offset="+23:59", to_offset="-23:59") + + def test_fixed_units_and_affine_temperatures(self): + for value, source, target, expected in (("1", "m", "cm", "100"), ("1", "mi", "m", "1609.344"), + ("1", "lb", "kg", "0.45359237"), ("32", "F", "C", "0"), + ("0", "C", "K", "273.15"), ("1", "l", "ml", "1000"), + ("36", "km/h", "m/s", "10"), ("1", "day", "h", "24"), + ("1", "pyeong", "sqm", "3.305785123967")): + with self.subTest(source=source, target=target): + self.assertEqual(self.request("unit.convert", value=value, from_unit=source, to_unit=target)["value"], expected) + self.assertEqual(self.request("unit.convert", value="400", from_unit="sqm", to_unit="pyeong")["value"], "121") + + def test_unit_dimension_and_physical_temperature_limits(self): + self.invalid("unit.convert", value="1", from_unit="kg", to_unit="m") + self.invalid("unit.convert", value="1", from_unit="month", to_unit="day") + self.invalid("unit.convert", value="-0.01", from_unit="K", to_unit="C") + self.invalid("unit.convert", value="-273.16", from_unit="C", to_unit="F") + self.assertEqual(self.request("unit.convert", value="-459.67", from_unit="F", to_unit="K")["value"], "0") + + def test_bmi_is_a_number_without_classification(self): + self.assertEqual(self.request("bmi", weight_kg="70", height_cm="175"), {"bmi": "22.86"}) + for weight, height in (("0", "175"), ("70", "0"), ("-1", "175"), ("70", "-175")): + self.invalid("bmi", weight_kg=weight, height_cm=height) + self.invalid("bmi", weight_kg="70", height_cm="175", age=10) + + def test_nonfinite_duplicate_unknown_or_malformed_json(self): + for raw in (b'{"operation":"percent.part","total":NaN,"rate_percent":1}', + b'{"operation":"percent.part","total":Infinity,"rate_percent":1}', + b'{"operation":"percent.part","total":1,"total":2,"rate_percent":1}', + b'{"operation":"date.add","date":"2025-01-01","x":{"a":1,"a":2}}', + b'{}', b'[]', b'null', b'true', b'{} {}', b'{', b'\xff', b'1e9999', + b'{"operation":"__import__"}', b'{"operation":"tax"}', b'{"operation":"loan","schedule":true}'): + with self.subTest(raw=raw): + response, status = engine.process_payload(raw) + self.assertEqual(status, 2) + self.assertFalse(response["ok"]) + + def test_input_precision_size_and_result_bounds(self): + for raw in (b' ' * (engine.MAX_INPUT_BYTES + 1), b'{"operation":"' + b'x' * 129 + b'"}', + b'{"operation":"x","items":' + b'[' * 10 + b'0' + b']' * 10 + b'}'): + self.assertEqual(engine.process_payload(raw)[1], 2) + self.invalid("interest.compound", principal="1000000000000000", annual_rate_percent="1000", years="100", compounds_per_year=1) + self.invalid("percent.part", total="1" * 81, rate_percent="1") + self.invalid("discount.single", price="1", rate_percent="1", places=7) + + def test_main_writes_one_json_line_and_uses_status_code(self): + raw = b'{"operation":"discount.single","price":"100","rate_percent":"20"}' + input_stream = io.TextIOWrapper(io.BytesIO(raw), encoding="utf-8") + output_stream = io.StringIO() + with patch.object(sys, "argv", ["calculate.py"]), patch.object(sys, "stdin", input_stream), patch.object(sys, "stdout", output_stream): + status = engine.main() + self.assertEqual(status, 0) + self.assertEqual(output_stream.getvalue().count("\n"), 1) + self.assertEqual(json.loads(output_stream.getvalue())["result"]["final"], "80.00") + output_stream = io.StringIO() + with patch.object(sys, "argv", ["calculate.py", "arbitrary.py"]), patch.object(sys, "stdout", output_stream): + self.assertEqual(engine.main(), 2) + self.assertFalse(json.loads(output_stream.getvalue())["ok"]) + + +if __name__ == "__main__": + unittest.main() From c636ff9cb6bebc9146908e6ec69ecff3f9e6e8a8 Mon Sep 17 00:00:00 2001 From: "Jung Hyun, Nam" Date: Sun, 25 Oct 2026 22:09:48 +0900 Subject: [PATCH 2/4] =?UTF-8?q?AI=20=EC=9D=91=EB=8B=B5=EB=B3=84=20?= =?UTF-8?q?=EC=8A=A4=ED=82=AC=20=EB=B0=8F=20=EB=8F=84=EA=B5=AC=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B8=B0=EB=A1=9D=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/SKILL_MARKETPLACE.ko.md | 10 +- docs/SKILL_MARKETPLACE.md | 10 +- .../ManagedAiResponseActivityView.cs | 118 ++++++++++ .../ManagedAi/ManagedAiWindow.cs | 50 +++- src/TableCloth.ManagedAi.Core/Contracts.cs | 10 +- .../OpenAiCodexProvider.cs | 6 +- .../Parsing/CodexJsonlParser.cs | 93 ++++++++ .../ActivityEvidenceTests.cs | 181 ++++++++++++++ .../ProviderTests.cs | 47 ++++ src/TableCloth.Test/ManagedAiWindowTests.cs | 221 +++++++++++++++++- 10 files changed, 729 insertions(+), 17 deletions(-) create mode 100644 src/TableCloth.App/ManagedAi/ManagedAiResponseActivityView.cs create mode 100644 src/TableCloth.ManagedAi.Test/ActivityEvidenceTests.cs diff --git a/docs/SKILL_MARKETPLACE.ko.md b/docs/SKILL_MARKETPLACE.ko.md index 63ebe6bf..bb1323e9 100644 --- a/docs/SKILL_MARKETPLACE.ko.md +++ b/docs/SKILL_MARKETPLACE.ko.md @@ -46,7 +46,13 @@ 스킬은 `%LOCALAPPDATA%\TableCloth\ManagedAi\profiles\openai-codex\codex-home\skills`에 보관합니다. 기존 가져오기 기능과 작업 잠금이 변경 작업을 제어합니다. 마켓플레이스는 이 프로필을 관리합니다. 사용자 전역 Codex 프로필과 스킬은 관리 범위에 포함하지 않습니다. [스킬 관리자](../src/TableCloth.ManagedAi.OpenAi/Skills/OpenAiCodexSkillManager.cs)와 [관리 경로](../src/TableCloth.ManagedAi.Core/ManagedAiPaths.cs)에서 저장 범위를 확인할 수 있습니다. -## 네이티브 Codex와 호스트 브리지의 실행 범위 +## 응답별 처리 기록과 실행 범위 + +각 응답 아래에 **스킬 및 도구 기록**을 표시합니다. 호스트 항목은 앱이 연동한 스킬 이름과 처리 완료, 실패 또는 완료 여부 미확인 상태를 표시합니다. MCP 항목은 Codex가 보고한 서버와 도구를 표시합니다. **MCP 응답 수신**은 도구 응답을 받았다는 뜻이며 스크립트의 성공이나 특정 스킬과의 연결을 입증하지 않습니다. [AI 창](../src/TableCloth.App/ManagedAi/ManagedAiWindow.cs)은 개별 응답에 해당 기록을 연결합니다. 메시지 복사 버튼은 응답의 마크다운 원문만 복사합니다. + +처리 기록으로 모델의 스킬 지침 참조 여부나 응답 반영 여부까지 확인하지는 못합니다. Codex 0.156.1의 [`exec --json` 항목 계약](https://github.com/openai/codex/blob/b412ff32c417f855c2b2d1581b77058eed87c84b/codex-rs/exec/src/exec_events.rs#L98)은 MCP 호출을 제공하지만 스킬 선택이나 지침 읽기 이벤트는 제공하지 않습니다. 따라서 기록이 없으면 확인된 스킬 실행 기록이 없다고 안내합니다. 스킬 미사용을 뜻하지는 않습니다. 설치 상태, 질문의 스킬 이름이나 모델이 작성한 사용 주장은 확인 근거에 포함하지 않습니다. 지침만 제공하는 스킬은 처리 기록 없이도 응답에 영향을 줄 수 있습니다. + +처리 메타데이터는 길이와 개수를 제한한 도구 식별자와 고정한 결과 구분만 보관합니다. 인수, 명령줄, 로컬 경로, 도구 결과와 오류 원문은 포함하지 않습니다. 일부 기록을 해석하거나 보관하지 못하면 기록이 불완전하다고 안내합니다. 앱은 현재 창의 메모리에서 기록을 관리하며 진단 로그나 다음 응답에 복사하지 않습니다. [JSONL 파서](../src/TableCloth.ManagedAi.OpenAi/Parsing/CodexJsonlParser.cs)에서 수집 범위를 확인할 수 있습니다. AI 사용 준비 과정에서는 대화와 검색에 사용하는 네이티브 Codex CLI를 설치합니다. Node.js와 Python은 설치하지 않습니다. 마켓플레이스에도 언어 런타임 설치, 버전 표시와 진단 기능을 제공하지 않습니다. [Codex 설치기](../src/TableCloth.ManagedAi.OpenAi/Distribution/OpenAiCodexPackageInstaller.cs)는 스킬 파일 설치기와 별도로 유지합니다. @@ -56,7 +62,7 @@ AI 사용 준비 과정에서는 대화와 검색에 사용하는 네이티브 C ## 자동 검증과 게시 경로의 구분 -자동 검증의 범위를 검토하겠습니다. 테스트는 검토 조건 없는 카탈로그 파싱, 다운로드 검증, 버전 추적, 교체 실패 시 복구, 제거와 뷰 모델 명령을 다룹니다. 백엔드 테스트는 합성 응답으로 실패와 복구 경로를 검사합니다. Avalonia 헤드리스 테스트는 언어, 테마와 창 크기를 달리해 마켓플레이스의 두 탭을 검사합니다. [스킬 테스트](../src/TableCloth.ManagedAi.Test/SkillMarketplaceTests.cs)와 [UI 테스트](../src/TableCloth.Test/SkillMarketplaceTests.cs)에 검증 항목을 기록했습니다. +자동 검증의 범위를 검토하겠습니다. 테스트는 검토 조건 없는 카탈로그 파싱, 다운로드 검증, 버전 추적, 교체 실패 시 복구, 제거와 뷰 모델 명령을 다룹니다. 백엔드 테스트는 합성 응답으로 실패와 복구 경로를 검사합니다. Avalonia 헤드리스 테스트는 언어, 테마와 창 크기를 달리해 마켓플레이스의 두 탭을 검사합니다. [스킬 테스트](../src/TableCloth.ManagedAi.Test/SkillMarketplaceTests.cs)와 [UI 테스트](../src/TableCloth.Test/SkillMarketplaceTests.cs)에 검증 항목을 기록했습니다. 처리 기록 검증은 MCP 이벤트 중복과 실패, 메타데이터 제한, 호스트 처리 결과, 응답 간 기록 분리와 실행 근거 없는 모델의 사용 주장을 다룹니다. [파서 테스트](../src/TableCloth.ManagedAi.Test)와 [대화 UI 테스트](../src/TableCloth.Test/ManagedAiWindowTests.cs)에서 사례를 확인할 수 있습니다. 테스트용 응답, 실제 저장소 다운로드와 네이티브 Codex 스킬 조회는 서로 다른 범위를 검증합니다. 테스트용 응답은 구현한 계약을 검사합니다. 실제 다운로드와 스킬 조회로는 설치 파일, 메타데이터와 활성 상태를 확인할 수 있습니다. 어느 결과도 모델의 스크립트 호출이나 외부 서비스, API 자격 증명, MCP 연결의 정상 동작을 입증하지 않습니다. 이 문서에서는 실제 서비스와 모델의 도구 실행을 검증한 것으로 안내하지 않습니다. 호스트 브리지도 로컬 CLI와 Windows 기능에 의존하므로 스킬 목록에 표시된 사실만으로 실제 호스트 작업의 성공을 판단하지 않습니다. diff --git a/docs/SKILL_MARKETPLACE.md b/docs/SKILL_MARKETPLACE.md index 1beb4abe..c4008362 100644 --- a/docs/SKILL_MARKETPLACE.md +++ b/docs/SKILL_MARKETPLACE.md @@ -46,7 +46,13 @@ A malformed `SKILL.md` remains visible with an invalid-manifest state. It can be Skills remain under `%LOCALAPPDATA%\TableCloth\ManagedAi\profiles\openai-codex\codex-home\skills`. TableCloth's existing importer and operation lock continue to control mutations. The marketplace manages this profile; the user's global Codex profile and skills remain outside its scope. See [the skill manager](../src/TableCloth.ManagedAi.OpenAi/Skills/OpenAiCodexSkillManager.cs) and [managed paths](../src/TableCloth.ManagedAi.Core/ManagedAiPaths.cs). -## Native Codex and host-bridge execution boundaries +## Response activity and execution boundaries + +Each assistant answer includes **Skill and tool activity** beneath its text. Host entries identify the skill whose application bridge ran and distinguish completed, failed, and unconfirmed operations. MCP entries identify the server and tool reported by Codex. **MCP response received** confirms a tool response, without claiming that a script succeeded or that the tool belongs to a particular skill. The [AI window](../src/TableCloth.App/ManagedAi/ManagedAiWindow.cs) keeps these records with the individual answer. Copying a message copies its original Markdown only. + +The footer also explains the limit of this evidence: it cannot confirm whether the model consulted skill instructions or reflected a result in its answer. Codex 0.156.1's [`exec --json` item contract](https://github.com/openai/codex/blob/b412ff32c417f855c2b2d1581b77058eed87c84b/codex-rs/exec/src/exec_events.rs#L98) exposes MCP calls but has no skill-selection or instruction-read event. An empty record therefore means no skill execution was confirmed, rather than proof that no skill was used. Installed state, a skill name in the question, and the model's own usage claim do not create evidence. Instruction-only skills can still affect an answer without producing an activity entry. + +Activity metadata contains bounded tool identities and fixed outcomes. It excludes arguments, command lines, local paths, tool results, and raw errors. Unsupported, conflicting, or excess MCP metadata produces an incomplete-evidence notice. Records remain in the current window's memory, do not enter the content-free diagnostic log, and do not carry over into the next answer. See the [JSONL parser](../src/TableCloth.ManagedAi.OpenAi/Parsing/CodexJsonlParser.cs). AI setup installs the native Codex CLI used for chat and search. It does not install Node.js or Python. The marketplace has no language-runtime installation, version, or diagnostic controls. The [Codex installer](../src/TableCloth.ManagedAi.OpenAi/Distribution/OpenAiCodexPackageInstaller.cs) remains separate from the skill-file installer. @@ -56,7 +62,7 @@ The certificate-expiry and Windows Sandbox integrations use application-owned ho ## Verification and publication boundaries -Automated checks cover catalog parsing without review gates, download validation, revision tracking, replacement rollback, removal, and view-model actions. Backend fixtures use synthetic responses to exercise failure and recovery paths. Headless Avalonia checks exercise the two marketplace tabs across languages, themes, and window sizes. See [skill tests](../src/TableCloth.ManagedAi.Test/SkillMarketplaceTests.cs) and [UI tests](../src/TableCloth.Test/SkillMarketplaceTests.cs). +Automated checks cover catalog parsing without review gates, download validation, revision tracking, replacement rollback, removal, and view-model actions. Backend fixtures use synthetic responses to exercise failure and recovery paths. Headless Avalonia checks exercise the two marketplace tabs across languages, themes, and window sizes. See [skill tests](../src/TableCloth.ManagedAi.Test/SkillMarketplaceTests.cs) and [UI tests](../src/TableCloth.Test/SkillMarketplaceTests.cs). Activity checks cover MCP lifecycle deduplication and failures, bounded metadata, host-operation outcomes, per-answer isolation, and model usage claims that have no execution evidence. See the [parser tests](../src/TableCloth.ManagedAi.Test) and [chat UI tests](../src/TableCloth.Test/ManagedAiWindowTests.cs). Fixture results, downloads from real repositories, and native Codex skill discovery establish different properties. Fixtures check the implemented contracts. Real downloads and discovery can confirm installed files, metadata, and enabled state. Neither establishes that the model invoked a script or that an external service, API credential, or MCP connection works. Actual service and model-tool execution remain unverified in this guide. The host bridges also depend on the local CLI and Windows capabilities; a skill listing alone does not establish successful host execution. diff --git a/src/TableCloth.App/ManagedAi/ManagedAiResponseActivityView.cs b/src/TableCloth.App/ManagedAi/ManagedAiResponseActivityView.cs new file mode 100644 index 00000000..dcce057a --- /dev/null +++ b/src/TableCloth.App/ManagedAi/ManagedAiResponseActivityView.cs @@ -0,0 +1,118 @@ +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Media; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; + +namespace TableCloth.ManagedAi; + +internal static class ManagedAiResponseActivityView +{ + public static Control Create(IReadOnlyList activities, bool evidenceIncomplete, bool responseCompleted) + { + var panel = new StackPanel { Spacing = 4, Margin = new Thickness(0, 6, 0, 0) }; + AutomationProperties.SetAutomationId(panel, "ManagedAiResponseActivity"); + panel.Children.Add(new TextBlock + { + Text = L("스킬 및 도구 기록", "Skill and tool activity"), FontWeight = FontWeight.SemiBold, + FontSize = 11, TextWrapping = TextWrapping.Wrap + }); + if (!responseCompleted) + panel.Children.Add(Text(L("응답을 완료하지 못했습니다. 아래 기록은 앱의 처리 내역만 나타냅니다.", + "The response did not complete. These records show app operations only."))); + if (activities.Count == 0) + panel.Children.Add(Text(L("확인된 스킬 실행 기록이 없습니다.", "No skill execution was confirmed."))); + else + { + var details = new StackPanel { Spacing = 3 }; + foreach (var activity in activities) + { + var name = Identifier(activity.Name); + var identity = activity.Kind == AiResponseActivityKind.McpTool + ? $"{Identifier(activity.Server)} / {name}" : name; + var row = Text($"{identity}: {OutcomeText(activity)}"); + AutomationProperties.SetAutomationId(row, "ManagedAiResponseActivityItem"); + details.Children.Add(row); + } + if (activities.Count > 3) + { + details.IsVisible = false; + // The application's theme does not provide an Expander template. + var disclosure = new Button + { + Content = L($"기록 {activities.Count}개 보기", $"View {activities.Count} activity records"), + FontSize = 11, Padding = new Thickness(6, 2), MinHeight = 24, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Left + }; + disclosure.Click += (_, _) => + { + details.IsVisible = !details.IsVisible; + disclosure.Content = details.IsVisible + ? L("기록 접기", "Hide activity records") + : L($"기록 {activities.Count}개 보기", $"View {activities.Count} activity records"); + }; + AutomationProperties.SetAutomationId(disclosure, "ManagedAiResponseActivityDetails"); + AutomationProperties.SetAutomationId(details, "ManagedAiResponseActivityItems"); + panel.Children.Add(disclosure); + panel.Children.Add(details); + } + else panel.Children.Add(details); + } + if (evidenceIncomplete) + panel.Children.Add(Text(L("일부 처리 기록을 표시하지 못했거나 완료 여부를 확인하지 못했습니다.", + "Some activity records could not be displayed or their completion confirmed."))); + var limit = L("스킬 지침 참조 여부와 응답 반영 여부는 확인할 수 없습니다.", + "Skill instruction use and its effect on the answer cannot be verified."); + if (activities.Any(activity => activity.Kind == AiResponseActivityKind.McpTool)) + limit += L(" MCP 기록만으로 스킬 사용을 확인할 수는 없습니다.", " MCP activity alone does not confirm skill use."); + panel.Children.Add(Text(limit)); + return panel; + } + + public static AiResponseActivityOutcome SandboxOutcome(SandboxCliRequest request, string report) + { + // Inspect only the app-owned status envelope. Output, paths, and identifiers never enter the activity UI. + try + { + using var json = JsonDocument.Parse(report, new JsonDocumentOptions { MaxDepth = 8 }); + var root = json.RootElement; + if (root.ValueKind != JsonValueKind.Object || + !root.TryGetProperty("Action", out var action) || action.ValueKind != JsonValueKind.String || + action.GetString() != request.Action.ToString().ToLowerInvariant() || + !root.TryGetProperty("Status", out var status) || status.ValueKind != JsonValueKind.String) + return AiResponseActivityOutcome.Incomplete; + return status.GetString() switch + { + "succeeded" when root.TryGetProperty("ExitCode", out var exitCode) && exitCode.TryGetInt32(out var code) && code == 0 + => AiResponseActivityOutcome.Completed, + "failed" or "unavailable" or "list-failed" => AiResponseActivityOutcome.Failed, + _ => AiResponseActivityOutcome.Incomplete + }; + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException) + { return AiResponseActivityOutcome.Incomplete; } + } + + private static string OutcomeText(AiResponseActivity activity) => (activity.Kind, activity.Outcome) switch + { + (AiResponseActivityKind.HostSkill, AiResponseActivityOutcome.Completed) + => L("앱 처리 완료", "App operation completed"), + (AiResponseActivityKind.HostSkill, AiResponseActivityOutcome.Failed) + => L("앱 처리 실패", "App operation failed"), + (AiResponseActivityKind.McpTool, AiResponseActivityOutcome.Completed) + => L("MCP 응답 수신", "MCP response received"), + (AiResponseActivityKind.McpTool, AiResponseActivityOutcome.Failed) + => L("MCP 호출 실패", "MCP call failed"), + _ => L("완료 확인 불가", "Completion unconfirmed") + }; + + private static string Identifier(string? value) => !string.IsNullOrEmpty(value) && value.Length <= 128 && + Array.TrueForAll(value.ToCharArray(), c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_' or '.') + ? value : L("식별 정보 없음", "Identity unavailable"); + + private static TextBlock Text(string value) => new() { Text = value, TextWrapping = TextWrapping.Wrap, FontSize = 11 }; + private static string L(string korean, string english) => ManagedAiText.Select(korean, english); +} diff --git a/src/TableCloth.App/ManagedAi/ManagedAiWindow.cs b/src/TableCloth.App/ManagedAi/ManagedAiWindow.cs index 1fdfe4df..81c958c0 100644 --- a/src/TableCloth.App/ManagedAi/ManagedAiWindow.cs +++ b/src/TableCloth.App/ManagedAi/ManagedAiWindow.cs @@ -454,7 +454,8 @@ private static Border CreatePrivacyNotice() return notice; } - private void AddMessage(string author, string content, bool user = false) + private void AddMessage(string author, string content, bool user = false, + AiChatResponse? response = null, IReadOnlyList? failedActivities = null) { var body = new StackPanel { Spacing = 8 }; var heading = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto"), ColumnSpacing = 12 }; @@ -481,6 +482,10 @@ private void AddMessage(string author, string content, bool user = false) body.Children.Add(heading); body.Children.Add(new ChatMarkdownView(content, async link => await RunAsync(token => _session.OpenLinkAsync(link, token)))); + if (response is not null) + body.Children.Add(ManagedAiResponseActivityView.Create(response.Activities, response.ActivityEvidenceIncomplete, responseCompleted: true)); + else if (failedActivities is { Count: > 0 }) + body.Children.Add(ManagedAiResponseActivityView.Create(failedActivities, evidenceIncomplete: true, responseCompleted: false)); var bubble = new Border { Child = body, Padding = new Thickness(16, 12), CornerRadius = new CornerRadius(12), MaxWidth = 690, @@ -515,6 +520,7 @@ private async Task SendAsync() } var certificateLookup = _certificateSkillEnabled && CertificateExpiryIntent.Matches(text); var sandboxRequest = _sandboxSkillEnabled ? WindowsSandboxIntent.Parse(text) : null; + var localActivities = new List(); await RunAsync(async token => { if (certificateLookup && _messages.DisplayQuestion( @@ -539,14 +545,17 @@ await RunAsync(async token => { _status.Text = L("로컬 인증서 만료일을 확인하고 있습니다.", "Checking local certificate expiry dates."); if (_pendingStatus is not null) _pendingStatus.Text = _status.Text; - localReport = await RunBackendAsync(_certificateBridge.GetExpiryReportAsync, token); + localReport = await RunHostSkillAsync("tablecloth-certificate-expiry", _certificateBridge.GetExpiryReportAsync, + _ => AiResponseActivityOutcome.Completed, token); } if (sandboxRequest is not null) { _status.Text = sandboxRequest.SafeMode ? SafeModeText.Status : L("Windows Sandbox 명령을 실행하고 있습니다.", "Running the Windows Sandbox command."); if (_pendingStatus is not null) _pendingStatus.Text = _status.Text; - localSandboxReport = await RunBackendAsync(ct => _sandboxBridge.ExecuteAsync(sandboxRequest, ct), token); + localSandboxReport = await RunHostSkillAsync("tablecloth-windows-sandbox", + ct => _sandboxBridge.ExecuteAsync(sandboxRequest, ct), + report => ManagedAiResponseActivityView.SandboxOutcome(sandboxRequest, report), token); } var progress = Progress(); AiChatResponse response; @@ -561,12 +570,34 @@ await RunAsync(async token => throw; } RemovePending(); - AddMessage($"{L("식탁보", "TableCloth")} / {response.Model ?? model.Id}", response.Text); + response = response with { Activities = localActivities.Concat(response.Activities).ToArray() }; + AddMessage($"{L("식탁보", "TableCloth")} / {response.Model ?? model.Id}", response.Text, response: response); _status.Text = response.SearchCalls > 0 ? L($"응답을 완료했습니다. 웹 검색 {response.SearchCalls}회", $"Response complete. {response.SearchCalls} web searches.") : L("응답을 완료했습니다.", "Response complete."); - }, errorInChat: true); + }, errorInChat: true, failureActivities: () => localActivities); _input.Focus(); + + async Task RunHostSkillAsync(string id, Func> execute, + Func outcome, CancellationToken token) + { + try + { + var report = await RunBackendAsync(execute, token); + localActivities.Add(new(AiResponseActivityKind.HostSkill, id, outcome(report))); + return report; + } + catch (OperationCanceledException) + { + localActivities.Add(new(AiResponseActivityKind.HostSkill, id, AiResponseActivityOutcome.Incomplete)); + throw; + } + catch + { + localActivities.Add(new(AiResponseActivityKind.HostSkill, id, AiResponseActivityOutcome.Failed)); + throw; + } + } } private void SanitizeInput() @@ -606,7 +637,8 @@ private async Task SendWithBusyRetryAsync(Func action, bool errorInChat = false, bool keepSettingsOpen = false) + private async Task RunAsync(Func action, bool errorInChat = false, bool keepSettingsOpen = false, + Func>? failureActivities = null) { if (_operation is not null) return; if (!keepSettingsOpen) _settingsOverlay.IsVisible = false; @@ -645,7 +677,11 @@ private async Task RunAsync(Func action, bool errorInCh UpdateEnabled(); if (_closing) Close(); } - void ShowFailure(string message) { _status.Text = message; if (errorInChat) AddMessage(L("안내", "Notice"), message); } + void ShowFailure(string message) + { + _status.Text = message; + if (errorInChat) AddMessage(L("안내", "Notice"), message, failedActivities: failureActivities?.Invoke()); + } } // Backend methods can perform synchronous filesystem and process setup before their first await. diff --git a/src/TableCloth.ManagedAi.Core/Contracts.cs b/src/TableCloth.ManagedAi.Core/Contracts.cs index aa3c2bb7..528f27ba 100644 --- a/src/TableCloth.ManagedAi.Core/Contracts.cs +++ b/src/TableCloth.ManagedAi.Core/Contracts.cs @@ -49,7 +49,15 @@ public sealed record AiClientContext(Version? InstalledVersion, Uri OfficialHome public sealed record AiChatRequest(string Message, IReadOnlyList History, string? Model = null, string? LocalCertificateReport = null, string? LocalWindowsSandboxReport = null, AiClientContext? ClientContext = null); -public sealed record AiChatResponse(string Text, DateTimeOffset RetrievedAtUtc, int SearchCalls, string? Model = null); +public enum AiResponseActivityKind { HostSkill, McpTool } +public enum AiResponseActivityOutcome { Completed, Failed, Incomplete } +public sealed record AiResponseActivity(AiResponseActivityKind Kind, string Name, + AiResponseActivityOutcome Outcome, string? Server = null); +public sealed record AiChatResponse(string Text, DateTimeOffset RetrievedAtUtc, int SearchCalls, string? Model = null) +{ + public IReadOnlyList Activities { get; init; } = []; + public bool ActivityEvidenceIncomplete { get; init; } +} public sealed record AiModel(string Id, string DisplayName, bool IsDefault = false) { public AiModelTokenCost? TokenCost { get; init; } diff --git a/src/TableCloth.ManagedAi.OpenAi/OpenAiCodexProvider.cs b/src/TableCloth.ManagedAi.OpenAi/OpenAiCodexProvider.cs index e07e7eeb..734ad4d8 100644 --- a/src/TableCloth.ManagedAi.OpenAi/OpenAiCodexProvider.cs +++ b/src/TableCloth.ManagedAi.OpenAi/OpenAiCodexProvider.cs @@ -18,7 +18,11 @@ public async Task ChatAsync(AiChatRequest request, IProgress new AiChatResponse(parser.CompleteText(exitCode), DateTimeOffset.UtcNow, parser.SearchCalls, request.Model), progress, cancellationToken, request.Model); + (parser, exitCode) => new AiChatResponse(parser.CompleteText(exitCode), DateTimeOffset.UtcNow, parser.SearchCalls, request.Model) + { + Activities = parser.Activities, + ActivityEvidenceIncomplete = parser.ActivityEvidenceIncomplete + }, progress, cancellationToken, request.Model); } private async Task RunAsync(string prompt, bool structured, Func complete, diff --git a/src/TableCloth.ManagedAi.OpenAi/Parsing/CodexJsonlParser.cs b/src/TableCloth.ManagedAi.OpenAi/Parsing/CodexJsonlParser.cs index 6a9f16a3..0be2cb32 100644 --- a/src/TableCloth.ManagedAi.OpenAi/Parsing/CodexJsonlParser.cs +++ b/src/TableCloth.ManagedAi.OpenAi/Parsing/CodexJsonlParser.cs @@ -4,10 +4,16 @@ namespace TableCloth.ManagedAi.OpenAi; public sealed class CodexJsonlParser { + private const int MaxActivities = 128; + private const int MaxIdentityLength = 128; + private readonly List _activities = []; + private readonly Dictionary _activityIndexes = new(StringComparer.Ordinal); public int EventCount { get; private set; } public int UnknownEvents { get; private set; } public int SearchCalls { get; private set; } public string Stage { get; private set; } = "Thinking"; + public IReadOnlyList Activities => Array.AsReadOnly(_activities.ToArray()); + public bool ActivityEvidenceIncomplete { get; private set; } private bool _completed; private bool _failed; private string? _lastMessage; @@ -45,10 +51,14 @@ public void Accept(string line) !item.TryGetProperty("type", out var itemType)) break; if (itemType.GetString() == "web_search") { SearchCalls++; Stage = "Thinking"; } if (itemType.GetString() == "agent_message") { _lastMessage = item.GetProperty("text").GetString(); Stage = "Writing"; } + if (itemType.GetString() == "mcp_tool_call") ReadMcpActivity(item, completed: true); break; case "item.started": case "item.updated": if (root.TryGetProperty("item", out var active) && active.TryGetProperty("type", out var activeType)) + { Stage = activeType.GetString() switch { "web_search" => "Searching", "agent_message" => "Writing", _ => "Thinking" }; + if (activeType.GetString() == "mcp_tool_call") ReadMcpActivity(active, completed: false); + } break; case "thread.started": case "turn.started": break; default: UnknownEvents++; break; @@ -74,6 +84,89 @@ public string CompleteText(int exitCode) return _lastMessage; } + private void ReadMcpActivity(JsonElement item, bool completed) + { + // Provider event metadata is evidence of a tool exchange, not proof that an answer used a skill. + // Retain no arguments, output, errors, paths, or model-supplied attribution. + if (!TryReadIdentity(item, "id", out var id) || !TryReadIdentity(item, "server", out var server) || + !TryReadIdentity(item, "tool", out var tool)) + { + ActivityEvidenceIncomplete = true; + return; + } + + var outcome = ReadMcpOutcome(item, completed); + if (_activityIndexes.TryGetValue(id, out var index)) + { + var previous = _activities[index]; + if (previous.Name != tool || previous.Server != server) + { + ActivityEvidenceIncomplete = true; + return; + } + // A late start/update or an incomplete duplicate cannot erase a terminal result. + if (completed && previous.Outcome != AiResponseActivityOutcome.Incomplete && + outcome != AiResponseActivityOutcome.Incomplete && previous.Outcome != outcome) + ActivityEvidenceIncomplete = true; + if (previous.Outcome == AiResponseActivityOutcome.Failed || outcome == AiResponseActivityOutcome.Incomplete) return; + _activities[index] = previous with { Outcome = outcome }; + return; + } + if (_activities.Count >= MaxActivities) + { + ActivityEvidenceIncomplete = true; + return; + } + _activityIndexes.Add(id, _activities.Count); + _activities.Add(new(AiResponseActivityKind.McpTool, tool, outcome, server)); + } + + private AiResponseActivityOutcome ReadMcpOutcome(JsonElement item, bool completed) + { + var status = item.TryGetProperty("status", out var statusValue) && statusValue.ValueKind == JsonValueKind.String + ? statusValue.GetString() : null; + if (status == "failed" || item.TryGetProperty("error", out var error) && error.ValueKind != JsonValueKind.Null) + return AiResponseActivityOutcome.Failed; + + var hasResult = item.TryGetProperty("result", out var result) && result.ValueKind == JsonValueKind.Object; + if (hasResult) + { + // Codex 0.156.1 translates MCP isError into status=failed and omits it from the result. + // Also accept the protocol flag defensively if a future event variant retains it. + foreach (var field in new[] { "isError", "is_error" }) + { + if (!result.TryGetProperty(field, out var isError)) continue; + if (isError.ValueKind == JsonValueKind.True) return AiResponseActivityOutcome.Failed; + if (isError.ValueKind != JsonValueKind.False) + { + ActivityEvidenceIncomplete = true; + return AiResponseActivityOutcome.Incomplete; + } + } + } + if (!completed) + { + if (status != "in_progress") ActivityEvidenceIncomplete = true; + return AiResponseActivityOutcome.Incomplete; + } + if (status == "completed" && hasResult && result.TryGetProperty("content", out var content) && + content.ValueKind == JsonValueKind.Array) + return AiResponseActivityOutcome.Completed; + ActivityEvidenceIncomplete = true; + return AiResponseActivityOutcome.Incomplete; + } + + private static bool TryReadIdentity(JsonElement item, string field, out string value) + { + value = string.Empty; + if (!item.TryGetProperty(field, out var property) || property.ValueKind != JsonValueKind.String) return false; + var text = property.GetString(); + if (string.IsNullOrEmpty(text) || text.Length > MaxIdentityLength || + text.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '_' and not '-' and not '.')) return false; + value = text; + return true; + } + private void ReadFailure(JsonElement root) { if (root.TryGetProperty("message", out var message) && message.ValueKind == JsonValueKind.String) diff --git a/src/TableCloth.ManagedAi.Test/ActivityEvidenceTests.cs b/src/TableCloth.ManagedAi.Test/ActivityEvidenceTests.cs new file mode 100644 index 00000000..d403ecc3 --- /dev/null +++ b/src/TableCloth.ManagedAi.Test/ActivityEvidenceTests.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using TableCloth.ManagedAi.OpenAi; + +namespace TableCloth.ManagedAi.Test; + +[TestClass] +public sealed class ActivityEvidenceTests +{ + [TestMethod] + public void McpLifecycleKeepsOneRecordAndOnlySafeIdentityMetadata() + { + var parser = new CodexJsonlParser(); + parser.Accept(Mcp("item.started", "item_1", "in_progress")); + Assert.AreEqual(AiResponseActivityOutcome.Incomplete, parser.Activities.Single().Outcome); + parser.Accept(Mcp("item.updated", "item_1", "in_progress")); + parser.Accept(Mcp("item.completed", "item_1", "completed", new + { + content = new[] { new { type = "text", text = "private result C:\\private\\file.txt" } }, + structured_content = new { access_token = "private structured result" } + })); + parser.Accept(Mcp("item.started", "item_1", "in_progress")); + parser.Accept(Mcp("item.completed", "item_1", "completed", new { content = Array.Empty() })); + + var activity = parser.Activities.Single(); + Assert.AreEqual(new AiResponseActivity(AiResponseActivityKind.McpTool, "calculator-tool", + AiResponseActivityOutcome.Completed, "fixture_server"), activity); + Assert.IsFalse(parser.ActivityEvidenceIncomplete); + var retained = JsonSerializer.Serialize(parser.Activities); + Assert.DoesNotContain("private", retained); + Assert.DoesNotContain("file.txt", retained); + Assert.DoesNotContain("access_token", retained); + Assert.DoesNotContain("arguments", retained); + } + + [TestMethod] + public void McpFailuresAreDistinctFromCompletedExchangesAndDoNotRegress() + { + var parser = new CodexJsonlParser(); + parser.Accept(Mcp("item.completed", "item_failed", "failed", new { content = Array.Empty() })); + parser.Accept(Mcp("item.completed", "item_error", "completed", new { content = Array.Empty() }, + new { message = "private transport failure" })); + parser.Accept(Mcp("item.completed", "item_isError", "completed", new { content = Array.Empty(), isError = true })); + parser.Accept(Mcp("item.completed", "item_is_error", "completed", new { content = Array.Empty(), is_error = true })); + parser.Accept(Mcp("item.started", "item_failed", "in_progress")); + parser.Accept(Mcp("item.completed", "item_failed", "completed", new { content = Array.Empty() })); + + Assert.HasCount(4, parser.Activities); + Assert.IsTrue(parser.Activities.All(x => x.Outcome == AiResponseActivityOutcome.Failed)); + Assert.DoesNotContain("private", JsonSerializer.Serialize(parser.Activities)); + } + + [TestMethod] + public void ContradictoryIdentityOrTerminalStatusMarksEvidenceIncomplete() + { + var parser = new CodexJsonlParser(); + parser.Accept(Mcp("item.completed", "item_1", "completed", new { content = Array.Empty() })); + parser.Accept(Mcp("item.completed", "item_1", "failed", tool: "different-tool")); + Assert.AreEqual("calculator-tool", parser.Activities.Single().Name); + Assert.AreEqual(AiResponseActivityOutcome.Completed, parser.Activities.Single().Outcome); + Assert.IsTrue(parser.ActivityEvidenceIncomplete); + + parser.Accept(Mcp("item.completed", "item_1", "failed")); + Assert.AreEqual(AiResponseActivityOutcome.Failed, parser.Activities.Single().Outcome); + } + + [TestMethod] + [DataRow("completed", "failed")] + [DataRow("failed", "completed")] + public void ConflictingTerminalOutcomesKeepFailureAndMarkEvidenceIncomplete(string first, string second) + { + var parser = new CodexJsonlParser(); + parser.Accept(Mcp("item.completed", "item_1", first, new { content = Array.Empty() })); + Assert.IsFalse(parser.ActivityEvidenceIncomplete); + parser.Accept(Mcp("item.completed", "item_1", second, new { content = Array.Empty() })); + Assert.AreEqual(AiResponseActivityOutcome.Failed, parser.Activities.Single().Outcome); + Assert.IsTrue(parser.ActivityEvidenceIncomplete); + } + + [TestMethod] + public void StartedOrUpdatedWithoutTerminalEventRemainsIncomplete() + { + var parser = new CodexJsonlParser(); + parser.Accept(Mcp("item.started", "item_1", "in_progress")); + parser.Accept(Mcp("item.updated", "item_1", "in_progress")); + Assert.AreEqual(AiResponseActivityOutcome.Incomplete, parser.Activities.Single().Outcome); + Assert.IsFalse(parser.ActivityEvidenceIncomplete); + } + + [TestMethod] + [DataRow("{}")] + [DataRow("{\"content\":null}")] + [DataRow("{\"content\":{}}")] + [DataRow("{\"content\":[],\"isError\":\"true\"}")] + [DataRow("null")] + public void MissingOrMalformedResultCannotProveCompletedExchange(string result) + { + var parser = new CodexJsonlParser(); + parser.Accept(Mcp("item.completed", "item_1", "completed", JsonSerializer.Deserialize(result))); + Assert.AreEqual(AiResponseActivityOutcome.Incomplete, parser.Activities.Single().Outcome); + Assert.IsTrue(parser.ActivityEvidenceIncomplete); + } + + [TestMethod] + [DataRow(null)] + [DataRow("future_status")] + [DataRow("in_progress")] + public void MissingOrUnknownTerminalStatusCannotProveCompletedExchange(string? status) + { + var parser = new CodexJsonlParser(); + parser.Accept(Mcp("item.completed", "item_1", status, new { content = Array.Empty() })); + Assert.AreEqual(AiResponseActivityOutcome.Incomplete, parser.Activities.Single().Outcome); + Assert.IsTrue(parser.ActivityEvidenceIncomplete); + } + + [TestMethod] + [DataRow("tool", "C:\\private\\skill.py")] + [DataRow("server", "https://user:password@example.com")] + [DataRow("tool", "tool\nforged line")] + [DataRow("tool", "스킬")] + [DataRow("id", "")] + public void UnsafeOrMissingIdentitiesAreSkippedWithoutRejectingTheAnswer(string field, string value) + { + var item = new Dictionary + { + ["type"] = "mcp_tool_call", ["id"] = "item_1", ["server"] = "fixture_server", ["tool"] = "calculator-tool", + ["status"] = "completed", ["result"] = new { content = Array.Empty() } + }; + item[field] = value; + var parser = new CodexJsonlParser(); + parser.Accept(JsonSerializer.Serialize(new { type = "item.completed", item })); + CompleteAnswer(parser); + Assert.HasCount(0, parser.Activities); + Assert.IsTrue(parser.ActivityEvidenceIncomplete); + Assert.AreEqual("Fixture answer", parser.CompleteText(0)); + } + + [TestMethod] + public void ActivityCountAndIdentityLengthsAreBoundedAndSnapshotsStayStable() + { + var parser = new CodexJsonlParser(); + parser.Accept(Mcp("item.completed", "long_tool", "completed", new { content = Array.Empty() }, tool: new string('a', 129))); + Assert.HasCount(0, parser.Activities); + for (var i = 0; i < 129; i++) + parser.Accept(Mcp("item.started", $"item_{i}", "in_progress")); + var before = parser.Activities; + Assert.HasCount(128, before); + Assert.IsTrue(parser.ActivityEvidenceIncomplete); + parser.Accept(Mcp("item.completed", "item_0", "completed", new { content = Array.Empty() })); + Assert.AreEqual(AiResponseActivityOutcome.Incomplete, before[0].Outcome); + Assert.AreEqual(AiResponseActivityOutcome.Completed, parser.Activities[0].Outcome); + } + + [TestMethod] + public void ModelAssertionsInstalledSkillsAndUnrelatedToolsNeverBecomeSkillEvidence() + { + var parser = new CodexJsonlParser(); + parser.Accept("{\"type\":\"item.completed\",\"item\":{\"type\":\"command_execution\",\"command\":\"python skill.py\",\"exit_code\":0}}"); + parser.Accept("{\"type\":\"item.completed\",\"item\":{\"type\":\"web_search\"}}"); + parser.Accept("{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"I used tablecloth-calculators. used_skills=[calculator]\"}}"); + parser.Accept("{\"type\":\"turn.completed\"}"); + Assert.Contains("used_skills", parser.CompleteText(0)); + Assert.HasCount(0, parser.Activities); + Assert.IsFalse(parser.ActivityEvidenceIncomplete); + Assert.HasCount(0, new CodexJsonlParser().Activities); + Assert.HasCount(0, new AiChatResponse("answer", DateTimeOffset.UtcNow, 0).Activities); + } + + private static string Mcp(string type, string id, string? status, object? result = null, object? error = null, + string tool = "calculator-tool") => JsonSerializer.Serialize(new + { + type, + item = new { type = "mcp_tool_call", id, server = "fixture_server", tool, status, + arguments = new { access_token = "private argument", path = "C:\\private\\file.txt" }, result, error } + }); + + private static void CompleteAnswer(CodexJsonlParser parser) + { + parser.Accept("{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"Fixture answer\"}}"); + parser.Accept("{\"type\":\"turn.completed\"}"); + } +} diff --git a/src/TableCloth.ManagedAi.Test/ProviderTests.cs b/src/TableCloth.ManagedAi.Test/ProviderTests.cs index cfd3788f..d96e1ca9 100644 --- a/src/TableCloth.ManagedAi.Test/ProviderTests.cs +++ b/src/TableCloth.ManagedAi.Test/ProviderTests.cs @@ -86,9 +86,55 @@ public async Task NativeCodexRequestsLeaveFormerSkillToolInstallationsInactiveAn Assert.HasCount(1, Directory.GetFileSystemEntries(directory!)); } + [TestMethod] + public async Task ChatAttachesObservedActivitiesWithoutRetainingPayloadsOrLeakingAcrossTurns() + { + using var fixture = new InstallFixture(); + await fixture.Installer.InstallAsync(null, null, CancellationToken.None); + var runner = new ResponseRunner("Fixture answer", fixture.Paths) + { + ActivityEvents = + [ + JsonSerializer.Serialize(new { type = "item.completed", item = new + { + type = "mcp_tool_call", id = "item_1", server = "fixture_server", tool = "fixture_tool", status = "completed", + arguments = new { secret = "private argument" }, result = new { content = new[] { new { type = "text", text = "private result" } } }, error = (object?)null + } }), + JsonSerializer.Serialize(new { type = "item.completed", item = new + { + type = "mcp_tool_call", id = "item_2", server = "unsafe/server", tool = "fixture_tool", status = "failed" + } }) + ] + }; + var profile = new WindowsManagedAiProfile(fixture.Paths); + var auth = new OpenAiCodexAuthManager(fixture.Paths, fixture.Installer, profile, runner); + var skills = new OpenAiCodexSkillManager(fixture.Paths, fixture.Installer, profile, runner); + var provider = new OpenAiCodexProvider(fixture.Paths, fixture.Installer, auth, profile, runner, new(), new(fixture.Paths), skills); + + var first = await provider.ChatAsync(new("fixture", []), null, CancellationToken.None); + Assert.AreEqual(new AiResponseActivity(AiResponseActivityKind.McpTool, "fixture_tool", AiResponseActivityOutcome.Completed, + "fixture_server"), first.Activities.Single()); + Assert.IsTrue(first.ActivityEvidenceIncomplete); + Assert.DoesNotContain("private", JsonSerializer.Serialize(first)); + + runner.ActivityEvents = []; + var second = await provider.ChatAsync(new("followup", []), null, CancellationToken.None); + Assert.HasCount(0, second.Activities); + Assert.IsFalse(second.ActivityEvidenceIncomplete); + Assert.HasCount(1, first.Activities); + foreach (var log in Directory.GetFiles(fixture.Paths.Logs)) + { + var text = File.ReadAllText(log); + Assert.DoesNotContain("private", text); + Assert.DoesNotContain("fixture_tool", text); + Assert.DoesNotContain("fixture_server", text); + } + } + private sealed class ResponseRunner(string message, ManagedAiPaths paths) : IJsonlProcessRunner { public ProcessRunSpec? Invocation; + public IReadOnlyList ActivityEvents { get; set; } = []; public string ExternalManifest => Path.Combine(paths.Root, "external", "SKILL.md"); public Task RunAsync(ProcessRunSpec spec, Action stdout, Action? stderr, CancellationToken cancellationToken) { @@ -112,6 +158,7 @@ public Task RunAsync(ProcessRunSpec spec, Action stdout, else { Invocation = spec; + foreach (var activity in ActivityEvents) stdout(activity); stdout("{\"type\":\"item.completed\",\"item\":{\"type\":\"web_search\"}}"); stdout(JsonSerializer.Serialize(new { type = "item.completed", item = new { type = "agent_message", text = message } })); stdout("{\"type\":\"turn.completed\"}"); diff --git a/src/TableCloth.Test/ManagedAiWindowTests.cs b/src/TableCloth.Test/ManagedAiWindowTests.cs index c974455a..2af65f39 100644 --- a/src/TableCloth.Test/ManagedAiWindowTests.cs +++ b/src/TableCloth.Test/ManagedAiWindowTests.cs @@ -654,6 +654,7 @@ await headless.Dispatch(async () => await Ready(window); Assert.IsTrue(Find(window, "ManagedAiStarters").IsVisible); Assert.IsTrue(Find(window, "ManagedAiPrivacyNotice").IsEffectivelyVisible); + Assert.IsFalse(Controls(window).Any(x => AutomationProperties.GetAutomationId(x) == "ManagedAiResponseActivity")); Assert.HasCount(1, Find(window, "ManagedAiChatTranscript").Children); Assert.HasCount(3, Controls(window).OfType