From e8382764e00de99e9ae3c6b9cfa6c3c4cf46366d Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:21:54 -0700 Subject: [PATCH 01/27] docs(asset-gen): design spec for AI asset generation (3D gen/import + 2D image) Providers: Tripo/Meshy/Hunyuan (3D gen), Sketchfab (3D import), fal.ai/OpenRouter (2D image). Hybrid arch: GUI configures keys, MCP tools trigger, C# executes the provider call and imports. OS-secure-store key handling; keys never cross the bridge. Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv --- .../2026-06-28-ai-asset-generation-design.md | 293 ++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-ai-asset-generation-design.md diff --git a/docs/superpowers/specs/2026-06-28-ai-asset-generation-design.md b/docs/superpowers/specs/2026-06-28-ai-asset-generation-design.md new file mode 100644 index 000000000..19cc5092f --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-ai-asset-generation-design.md @@ -0,0 +1,293 @@ +# AI Asset Generation for Unity-MCP — Design Spec + +- **Status:** Approved (greenlit 2026-06-28) +- **Branch / worktree:** `feat/3d-asset-generation` at `.worktrees/3d-asset-generation` +- **Author:** Claude Code (brainstormed with @Scriptwonder) +- **Supersedes discussion:** research workflow `wf_f5c9a569-1d9` (3D + Unity-MCP arch) + 2D image-gen survey + +## 1. Summary + +Add an **AI Asset Generation** capability to MCP for Unity: users bring their own +provider API keys, and the plugin runs **3D model generation**, **3D marketplace +import**, and **2D image generation** itself, importing results straight into the +Unity project. + +Modeled on BlenderMCP's "keys live in the editor, the MCP server holds nothing" +security model, adapted to Unity's AssetDatabase import model and Unity-MCP's +Python↔C# domain symmetry. + +Two front doors, one engine: + +- **GUI tab** (`Asset Generation`) — **configuration only**: enter/validate provider + keys (stored in the OS secure store), toggle providers, see recent-job status. + **No generation is triggered from the GUI.** +- **MCP tools + CLI** — the **only** way to trigger generation. The Python tool is a + thin pass-through (no key, no bytes); the **C# side executes the provider HTTPS + call**, downloads the result, and imports it. + +## 2. Goals / Non-goals + +### Goals +- One coherent `asset_gen` tool group covering 3D generation, 3D marketplace import, + and 2D image generation, **off by default** (enabled via `manage_tools`, like vfx/animation). +- Bring-your-own-key with **strong at-rest key security** (OS secure store, never plaintext). +- Keys never leave the editor process, never cross the bridge, never appear in tool + output, logs, job records, or committed files. +- Async, domain-reload-safe job lifecycle (submit → poll `status` → import), reusing + the proven `PackageJobManager`/SessionState pattern. +- Heavy bytes (models/images) never traverse the MCP bridge — C# downloads via + `UnityWebRequest` directly into `Assets/`. +- Correct Unity import: `ModelImporter` (scale/materials/rig) for 3D; `TextureImporter` + (Sprite/Default, alpha, sRGB-vs-linear) for 2D. +- Full domain symmetry: every C# `[McpForUnityTool]` mirrored by a Python + `@mcp_for_unity_tool` and a Click CLI command, with tests on both sides. +- Accessible first-run: curated best/most-accessible providers; graceful, instructive + errors when an optional dependency (glTFast) or key is missing. + +### Non-goals (v1) +- No GUI "Generate" button / prompt box (generation is MCP/CLI-only, by request). +- No Stability AI integration (dropped by request). +- No full 2D PBR-material auto-assembly (deferred until a PBR-capable 2D provider like + fal PATINA / Scenario is added; v1 imports flat albedo / sprites with correct settings). +- No self-hosted Hunyuan (`LOCAL_API`) mode in v1 (official Tencent Cloud API only). +- No animation/rig retargeting beyond setting `ModelImporter.animationType`. + +## 3. Providers (v1) + +### 3D — generative (async submit → poll → download) +| Provider | Auth | Modalities | Formats | Notes | +|----------|------|-----------|---------|-------| +| **Tripo3D** ⭐ default | Bearer `tsk_` | text→3D, image→3D, multiview | GLB (native), FBX/OBJ/USDZ via convert | Best free tier; `POST /v2/openapi/task`, `GET /v2/openapi/task/{id}` | +| **Meshy** | Bearer | text→3D (preview→refine), image→3D | GLB/FBX/OBJ/USDZ | `api.meshy.ai/openapi/v2`; API needs user's Pro key | +| **Hunyuan3D** (Tencent) | SecretId+SecretKey, **TC3-HMAC-SHA256** | text→3D, image→3D | GLB/OBJ in ZIP | Heaviest adapter; `ai3d.tencentcloudapi.com` Submit/Query Job | + +### 3D — marketplace (search → preview → import; not generative) +| Provider | Auth | Flow | +|----------|------|------| +| **Sketchfab** | `Authorization: Token` | `GET /v3/search` → `GET /v3/models/{uid}` (thumb) → `GET /v3/models/{uid}/download` → signed glTF zip → extract+import | + +### 2D — image (aggregator; single general key) +| Provider | Auth | Notes | +|----------|------|-------| +| **fal.ai** ⭐ default | `Authorization: Key` | `POST queue.fal.run/{model}` → `request_id` → poll → result. Model id configurable (default FLUX). Background-removal + (future) PATINA PBR reachable as model slugs. | +| **OpenRouter** | Bearer | Unified key; image-capable multimodal models (e.g. `google/gemini-2.5-flash-image`). Default model configurable. | + +All providers sit behind **one async-job abstraction** so providers are adapters, not +rewrites, and future ones (Rodin, Scenario, Replicate, fal PATINA) drop in cleanly. +Sketchfab's synchronous search and fal/OpenRouter's sync paths collapse to no-poll cases. + +## 4. Architecture + +``` +Unity "Asset Generation" tab ──(write keys)──> ISecureKeyStore (OS Keychain/CredMan/libsecret) + ▲ read at call-time, C# only +AI agent / CLI ──> generate_model | import_model | generate_image (Python MCP tool / Click CLI) + │ thin pass-through: NO key, NO bytes — only {action, provider, params, job_id} + ▼ bridge (WebSocket hub / legacy TCP) +C# HandleCommand ──> AssetGenJobManager.StartJob(...) (GUID job, SessionState-persisted) + │ returns { job_id } immediately + ▼ +ProviderAdapter (UnityWebRequest) ── submit ──> poll ──> download to Assets/Generated/... + ▼ +ImportPipeline ── ModelImporter / TextureImporter ── (optional normalize) ──> { assetPath, guid } + ▼ +status action (job_id) ──> { state, progress, assetPath | error } (key-free, redacted) +``` + +**Why C# executes the provider call:** it is the only design that simultaneously +honors (a) keys entered in the GUI, (b) keys never leaving the editor / never crossing +the bridge, and (c) heavy bytes off the bridge + 64 MB-frame-cap-proof + transport +agnostic (local stdio, local HTTP, remote-hosted HTTP all behave identically). + +### 4.1 Components (new code) + +| Component | Path | Responsibility | +|-----------|------|----------------| +| Secure key store | `MCPForUnity/Editor/Security/SecureKeyStore/*` | Cross-platform at-rest key storage; redaction helpers | +| Provider adapters | `MCPForUnity/Editor/Services/AssetGen/Providers/*` | One class per provider; submit/poll/download; auth | +| Job manager | `MCPForUnity/Editor/Services/AssetGen/AssetGenJobManager.cs` | GUID jobs, SessionState, `EditorApplication.update` completion (mirror `PackageJobManager`) | +| Import pipeline | `MCPForUnity/Editor/Services/AssetGen/Import/{ModelImportPipeline,ImageImportPipeline}.cs` | Write to `Assets/`, drive importers, normalize | +| C# tools | `MCPForUnity/Editor/Tools/AssetGen/{GenerateModel,ImportModel,GenerateImage}.cs` | `[McpForUnityTool(..., Group="asset_gen", RequiresPolling=true)]` | +| GUI section | `MCPForUnity/Editor/Windows/Components/AssetGen/McpAssetGenSection.{cs,uxml}` | Config-only tab (keys, toggles, Test, glTFast notice, recent jobs) | +| Deps row | edit `MCPForUnityEditorWindow.cs` `BuildDependenciesSection` | Add glTFast (`com.unity.cloud.gltfast`) optional-dependency row | +| Python tools | `Server/src/services/tools/{generate_model,import_model,generate_image}.py` | Pass-through `@mcp_for_unity_tool(group="asset_gen")` | +| CLI | `Server/src/cli/commands/asset_gen.py` | Click group mirroring the tools | +| Tests | `Server/tests/test_asset_gen_*.py`, `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/*` | Both sides | + +### 4.2 Tool surface + +`generate_model` (providers: tripo, meshy, hunyuan) +- `action: generate` — `provider, mode(text|image), prompt?, imagePath?|imageUrl?, format(glb|fbx|obj|usdz), targetSize?, texture?, tier?, name?, outputFolder?` → `{ job_id }` +- `action: status` — `job_id` → `{ state, progress, assetPath?, error? }` +- `action: cancel` — `job_id` +- `action: list_providers` — `{ providers:[{id, configured, capabilities}] }` (no key values) + +`import_model` (provider: sketchfab) +- `action: search` — `query, categories?, downloadable?, count?, cursor?` → results + uids +- `action: preview` — `uid` → base64 thumbnail (preview-before-import, BlenderMCP-style) +- `action: import` — `uid, targetSize?, name?, outputFolder?` → `{ job_id }` (download+import async) +- `action: status` / `cancel` / `list_providers` + +`generate_image` (providers: fal, openrouter) +- `action: generate` — `provider, mode(text|image), prompt?, imagePath?|imageUrl?, model?, transparent?, width?, height?, name?, outputFolder?` → `{ job_id }` (sync providers resolve immediately) +- `action: remove_background` — `imagePath` → `{ job_id }` (fal BiRefNet/rembg) +- `action: status` / `cancel` / `list_providers` + +Default output folders: `Assets/Generated/Models/`, `Assets/Generated/Sketchfab/`, +`Assets/Generated/Images/`. Name collisions get a numeric suffix. + +## 5. Security design (key handling) + +The user's explicit requirement: keys must be safely stored, resistant to theft. + +### 5.1 At-rest storage — `ISecureKeyStore` +``` +bool TryGet(string providerId, out string apiKey); +void Set(string providerId, string apiKey); +void Delete(string providerId); +bool Has(string providerId); // existence only; never returns the value +``` +Service namespace: `MCPForUnity.AssetGen`. One factory `SecureKeyStore.Current` selects: + +- **macOS** — Keychain via `/usr/bin/security` generic passwords + (`add-generic-password -U -s MCPForUnity.AssetGen -a -w `, `find-…`, `delete-…`). +- **Windows** — Credential Manager via `advapi32` P/Invoke `CredWrite/CredRead/CredDelete` + (`CRED_TYPE_GENERIC`, target `MCPForUnity.AssetGen:`), DPAPI-backed by the OS. +- **Linux** — `secret-tool` (libsecret) when present; otherwise `EncryptedFileKeyStore`. +- **Fallback** `EncryptedFileKeyStore` — AES-256-GCM, key derived from a per-user random + salt (generated once, stored in the user profile dir, `chmod 600`) combined with a + machine identifier; ciphertext under the user app-data dir, **never** under `Assets/` + or the repo. Documented as weaker than an OS store. +- **Env override** (read-only, never persisted) — `MCPFORUNITY__API_KEY` for + CI/headless and power users. Resolution order: env → secure store. + +Multi-secret providers (Hunyuan SecretId+SecretKey) store a JSON blob under one entry. + +### 5.2 In-use hygiene ("no behavior to steal the key") +- Key read into a local variable **only** at the moment of the HTTP call; not cached on + job records, not held in static fields, cleared promptly. +- **Redaction helper** (`SecretRedactor`) applied on every log/error path; keys never + logged, never echoed. Provider error bodies are scrubbed before surfacing. +- **Never serialized** into `AssetGenJob` records (which persist to SessionState) — jobs + hold provider id + params + status only. +- **Never crosses the bridge**: Python/CLI payloads carry no key material; the C# side + reads from the store. `list_providers`/`get_status` expose only `configured: bool`. +- **No "read key" action** exists in any tool; the agent can trigger generation but can + never retrieve a key value. +- **Never written** to project files, `ConfigJsonBuilder` output, `.meta`, or anything + git-tracked. +- **Test/validate** buttons hit a cheap provider auth endpoint (Tripo balance, Sketchfab + `/v3/me`, Meshy/fal/OpenRouter account ping) and report only success/failure. + +### 5.3 Threat model +- **Protects against:** local plaintext disclosure (plist/registry), accidental git + commit of keys, leakage over the MCP bridge / into agent-visible output / into logs. +- **Residual (documented):** code running as the same OS user with the editor's + entitlements — notably the `execute_code` tool (arbitrary C# in-process) — can ask the + OS store for an item, exactly as the editor can. We mitigate by exposing no generic + key-read API, recommending least-privilege provider keys + easy revocation, and a UI + note. This is strictly better than today's plaintext `EditorPrefs.ApiKey`. + +EditorPrefs is still used for **non-secret** asset-gen config (selected provider, +default format, output folder, enabled toggles) under new `EditorPrefKeys` consts +(`MCPForUnity.AssetGen.*`). + +## 6. Import pipeline + +### 6.1 3D — `ModelImportPipeline` +- Download to `Assets/Generated/Models/.` (UnityWebRequest, streamed to disk). +- `AssetDatabase.ImportAsset(path, ForceUpdate)`; then drive **`ModelImporter`** + (greenfield — patterned on the `TextureImporter` branch in `ManageAsset.ModifyAsset`): + `globalScale`, `useFileScale`, `importMaterials`/`materialImportMode`, + `animationType`, then `WriteImportSettingsIfDirty` + reimport. +- **GLB/glTF** requires **glTFast** (`com.unity.cloud.gltfast`), an optional Deps-tab + dependency. If absent and a provider returns GLB: fail the job with an actionable + message ("Install glTFast from the Dependencies tab, or choose FBX output"). Prefer FBX + when the provider offers it. +- ZIP outputs (Hunyuan, Sketchfab): extract with path-traversal guard (reject `..`, + verify `abspath` stays within the temp dir), locate the model entry, import. +- **Auto-normalize** (default on, configurable): compute combined bounds, uniformly + scale root so the largest dimension == `targetSize` (default 1m), optional single-mesh + cleanup (analogue of BlenderMCP `_clean_imported_glb`). + +### 6.2 2D — `ImageImportPipeline` +- Write/decode to `Assets/Generated/Images/.png` (decode base64 in-process where + the provider returns it; else download; download expiring URLs immediately). +- `TextureImporter`: `textureType` Sprite (`alphaIsTransparency=true`, `mipmapEnabled=false`) + for sprites/icons vs Default; **sRGB for color maps, linear for normal/roughness/metallic**; + `NormalMap` type for normals; pixel-art → `filterMode=Point`, uncompressed. +- (Deferred) PBR-set → Unity `Material` assembly with correct map slots + smoothness + inversion, once a PBR-capable provider is added. + +## 7. Async job lifecycle + +Mirror `ManagePackages` + `PackageJobManager`: +- Tools declared `[McpForUnityTool(..., RequiresPolling = true, PollAction = "status", MaxPollSeconds = 300)]`. +- `generate`/`import` mint a GUID job, persist to **`SessionState`** (survives the + domain reload that AssetDatabase import triggers — `TryRecoverJob`, `DomainReloadTimeoutMs`), + return `{ job_id }` immediately. +- A completion callback on `EditorApplication.update` advances the provider poll + + download + import, then `CompleteJob`. +- `status` returns `{ state: queued|running|importing|done|failed|canceled, progress, + assetPath?, error? }`. + +## 8. Domain symmetry & registration + +- C# auto-discovered by `CommandRegistry` reflection via `[McpForUnityTool]`; command + name string must equal the Python tool's send name. +- New tool group `asset_gen` added to the group enum/registry on both sides, **disabled + by default**, toggled by `manage_tools` (parity with vfx/animation). +- Python tools strip `None` params, camelCase keys to match C# `ToolParams`. +- CLI commands use `@handle_unity_errors` + HTTP `run_command`. + +## 9. Testing strategy + +- **Python (pytest):** tool param mapping, action routing, status/job_id pass-through, + error shaping — provider HTTP fully mocked. Files `Server/tests/test_asset_gen_*.py`. +- **C# EditMode (`TestProjects/UnityMCPTests`):** + - `ISecureKeyStore` round-trip (set/get/delete/has) against the fallback + `EncryptedFileKeyStore` (deterministic, CI-safe); redaction helper; env-override. + - `AssetGenJobManager` lifecycle incl. simulated domain-reload recovery. + - Provider adapters against a stub `IHttpClient` (inject a fake transport; assert + request shape, auth header presence **without** asserting key value, poll loop, + error handling). TC3-HMAC signer has a known-vector unit test. + - Import pipeline: import a tiny fixture FBX/PNG, assert importer settings + normalize. + - **Key never leaks**: assert job records / status payloads / logs contain no key. +- **Cross-version:** run `tools/check-unity-versions.sh` for any `#if UNITY_*`/shimmed + API; route fragile `ModelImporter`/`UnityWebRequest`/UIToolkit calls through + `Unity*Compat` shims per CLAUDE.md. +- **Definition of "test ready":** Python tests green; C# compiles across the CI matrix; + EditMode tests authored; provider calls exercised against mocks. Live provider calls + + live Unity import verified manually by the user (needs real keys + a licensed editor). + +## 10. Risks & mitigations + +| Risk | Mitigation | +|------|-----------| +| Key theft / leakage | OS secure store, redaction, no key over bridge / in output / in job records / in git; documented `execute_code` residual | +| GLB unimportable (no built-in importer) | glTFast optional Deps dep; prefer FBX; actionable error when missing | +| Domain reload during import kills polling | SessionState-persisted jobs (`PackageJobManager` pattern) | +| Large files over bridge (64 MB cap) | C# downloads to `Assets/` directly; only JSON crosses the bridge | +| TC3-HMAC signing complexity (Hunyuan) | Isolated `TencentSigner` with known-answer test; sequenced after Tripo/Meshy | +| Expiring result URLs (fal/Sketchfab/BFL) | Download immediately inside the job | +| Provider rate-limit / cost / failures | Default cheapest tier; backoff; surface quota/failed-job (often refunded) via `status` | +| Unity version drift (2021→6.x→CoreCLR) | `Unity*Compat` shims + `tools/check-unity-versions.sh` | +| GUI/MCP code-path divergence | GUI is config-only; the single C# handler is the only generation path | + +## 11. Implementation phases (refined in the implementation plan) + +0. **Scaffold** — `asset_gen` group on both sides; `EditorPrefKeys.AssetGen.*`; folders; package wiring. +1. **SecureKeyStore** — interface + platform impls + fallback + redactor + tests. *(security-first)* +2. **Provider abstraction + Tripo** — adapter interface; Tripo end-to-end (submit/poll/download) on a stub HTTP transport + tests. +3. **Job manager + C# `generate_model` + ModelImportPipeline** — SessionState jobs; FBX import first, GLB via glTFast. +4. **Python `generate_model` + CLI + tests** — pass-through, mocked. +5. **GUI `Asset Generation` tab** — config-only; keys via SecureKeyStore; toggles; Test; glTFast notice; recent-jobs readout. +6. **Meshy + Hunyuan (TC3-HMAC) + Sketchfab `import_model`**. +7. **2D `generate_image`** — fal.ai + OpenRouter adapters + ImageImportPipeline. +8. **Deps glTFast row + docs + version-compat sweep + full test pass**. + +## 12. Open follow-ups (post-v1) +- 2D PBR-material auto-assembly (fal PATINA / Scenario / Leonardo). +- Aggregator-routed 3D (Hunyuan/Rodin/Tripo via fal/Replicate) as an alternative path. +- Hunyuan `LOCAL_API` self-hosted mode. +- Rodin (incl. the `vibecoding` free-trial key) as a premium 3D provider. From d0a6ef01f5426b97d2888350663f939af1174b8d Mon Sep 17 00:00:00 2001 From: Shutong Wu <51266340+Scriptwonder@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:00:12 -0700 Subject: [PATCH 02/27] docs(asset-gen): implementation plan (Phase 1 SecureKeyStore + Phase 8 integration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Partial — phases 0,2-7 to be authored just-in-time during incremental execution (plan-authoring workflow rate-limited on those phases). Phase 1 is fully detailed and serves as the task-granularity template. Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv --- .../plans/2026-06-28-ai-asset-generation.md | 2156 +++++++++++++++++ 1 file changed, 2156 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-ai-asset-generation.md diff --git a/docs/superpowers/plans/2026-06-28-ai-asset-generation.md b/docs/superpowers/plans/2026-06-28-ai-asset-generation.md new file mode 100644 index 000000000..1e52f93fc --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-ai-asset-generation.md @@ -0,0 +1,2156 @@ +# AI Asset Generation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add bring-your-own-key AI asset generation (3D model gen + 3D marketplace import + 2D image gen) to MCP for Unity, triggered only via MCP tools/CLI, executed C#-side, imported into the Unity project. + +**Architecture:** Hybrid — the Unity "Asset Generation" GUI tab only configures provider keys (in the OS secure store) and toggles; generation is triggered by Python MCP tools / Click CLI (thin pass-throughs carrying no key and no bytes); the C# editor side reads the key from the secure store, calls the provider via UnityWebRequest, downloads into Assets/, and imports via ModelImporter/TextureImporter, using a SessionState-backed async job manager that survives the import domain reload. + +**Tech Stack:** Unity Editor C# (UIToolkit, AssetDatabase, ModelImporter, UnityWebRequest, SessionState, optional glTFast), Python FastMCP + Click CLI, pytest, Unity EditMode tests. Providers: Tripo/Meshy/Hunyuan (3D gen), Sketchfab (3D import), fal.ai/OpenRouter (2D image). + +## Global Constraints + +- Branch/worktree: `feat/3d-asset-generation` at `.worktrees/3d-asset-generation`. Commit per task; do NOT push or open a PR. +- Tool group `asset_gen` is OFF by default (parity with vfx/animation); enabled via `manage_tools`. +- Keys live ONLY in the OS secure store (Keychain/Credential Manager/libsecret + AES-256-GCM fallback). Keys: never in EditorPrefs, never over the bridge, never in tool output / job records / logs / git, never returned by any tool. Non-secret config uses EditorPrefs `MCPForUnity.AssetGen.*`. +- Heavy bytes never cross the bridge — C# downloads to `Assets/Generated/` directly. +- Async long ops use `RequiresPolling=true, PollAction="status"`; jobs persist to SessionState and survive domain reloads (DomainReloadTimeoutMs=120000). +- GLB import requires glTFast (`com.unity.cloud.gltfast`), an OPTIONAL Deps-tab dependency; prefer FBX; fail GLB jobs with an actionable message when absent. +- Domain symmetry: each C# `[McpForUnityTool]` mirrored by a Python `@mcp_for_unity_tool` + a Click CLI command, with tests both sides. camelCase params via ToolParams; strip None in Python. +- Route version-fragile APIs (ModelImporter/UnityWebRequest/UIToolkit) through `MCPForUnity/Runtime/Helpers/Unity*Compat.cs` shims (CLAUDE.md policy); run `tools/check-unity-versions.sh` when touching them. +- Conventional commits, scope `asset-gen`; end each commit body with `Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv`. +- "Test ready" = Python pytest green; C# compiles across the CI matrix; EditMode tests authored; provider HTTP exercised against fakes. Live provider + live Unity import are user-verified. + +--- + +I have everything I need. Here is the Phase 1 plan. + +--- + +## Phase 1: SecureKeyStore (security-first) + +This phase builds the entire at-rest key-storage subsystem **before** any provider or tool can read a key. It delivers the `ISecureKeyStore` contract, all four platform implementations, the env-var override, the platform-selecting factory, and the `SecretRedactor`. Tests target the deterministic `EncryptedFileKeyStore` fallback (CI-safe) plus a guard that proves a serialized job record can never carry a key. + +**Prerequisites & ground truth (verified against the repo):** +- All new code lives in the existing `MCPForUnity.Editor` assembly. New files under `MCPForUnity/Editor/Security/SecureKeyStore/` are auto-included by `MCPForUnity/Editor/MCPForUnity.Editor.asmdef` (folder-based, `autoReferenced: true`). **No new asmdef.** +- Test files go under `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/`. The existing `MCPForUnityTests.Editor.asmdef` at `.../EditMode/` covers all subfolders, so **no new test asmdef** is needed. +- Internal test seams are reachable: `MCPForUnity/Editor/AssemblyInfo.cs` already declares `[assembly: InternalsVisibleTo("MCPForUnityTests.EditMode")]` (verified). +- Namespace for production code: `MCPForUnity.Editor.Security`. Namespace for tests: `MCPForUnity.Editor.Tests.EditMode.AssetGen` (mirrors the existing `...Tests.EditMode.Services`). +- `AesGcm`, `Rfc2898DeriveBytes(…, HashAlgorithmName)`, and `ProcessStartInfo.ArgumentList` require the project's **.NET Standard 2.1** API level (Unity 2021.2+ default). The encrypted store translates a `PlatformNotSupportedException` into an actionable error. + +**How to run the C# tests in this phase (no headless run is possible here without a license):** +- **Interactive:** open `TestProjects/UnityMCPTests` in Unity → `Window > General > Test Runner` → `EditMode` tab → run the `AssetGen` fixtures. +- **Headless (needs a Hub-licensed editor):** `python tools/local_harness.py --legs editmode` +- **Compile across the CI matrix:** `tools/check-unity-versions.sh` +- **"Expected FAIL" before impl** means: the test references a type that does not yet exist, so the entire EditMode assembly fails to compile and the Test Runner shows red compile errors. **"Expected PASS"** means the named fixture is green. +- When committing, add the `.meta` files Unity generates for each new `.cs` file and for each new folder. + +--- + +### Task 1.1: Key-store contract, constants, and env-var override + +**Files:** +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/ISecureKeyStore.cs` +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStoreConstants.cs` +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/EnvKeyOverride.cs` +- Test: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/EnvKeyOverrideTests.cs` + +**Interfaces:** +- Produces: `public interface ISecureKeyStore { bool TryGet(string,out string); void Set(string,string); void Delete(string); bool Has(string); }` +- Produces: `internal static class SecureKeyStoreConstants { const string ServiceName="MCPForUnity.AssetGen"; static readonly string[] ProviderIds; }` +- Produces: `internal static class EnvKeyOverride { static string EnvVarName(string); static bool TryGet(string,out string); }` — env var `MCPFORUNITY__API_KEY`. + +**Steps:** + +- [ ] **Step 1: Write the failing env-override test.** Create `EnvKeyOverrideTests.cs`: + ```csharp + using NUnit.Framework; + using MCPForUnity.Editor.Security; + + namespace MCPForUnity.Editor.Tests.EditMode.AssetGen + { + [TestFixture] + public class EnvKeyOverrideTests + { + private const string Var = "MCPFORUNITY_TRIPO_API_KEY"; + + [TearDown] + public void TearDown() => System.Environment.SetEnvironmentVariable(Var, null); + + [Test] + public void EnvVarName_MapsProviderId_UpperWithApiKeySuffix() + { + Assert.AreEqual("MCPFORUNITY_TRIPO_API_KEY", EnvKeyOverride.EnvVarName("tripo")); + Assert.AreEqual("MCPFORUNITY_OPENROUTER_API_KEY", EnvKeyOverride.EnvVarName("openrouter")); + } + + [Test] + public void TryGet_ReturnsValue_WhenEnvSet() + { + System.Environment.SetEnvironmentVariable(Var, "tsk_env_value_123"); + Assert.IsTrue(EnvKeyOverride.TryGet("tripo", out var v)); + Assert.AreEqual("tsk_env_value_123", v); + } + + [Test] + public void TryGet_ReturnsFalse_WhenEnvMissing() + { + System.Environment.SetEnvironmentVariable(Var, null); + Assert.IsFalse(EnvKeyOverride.TryGet("tripo", out var v)); + Assert.IsNull(v); + } + } + } + ``` + +- [ ] **Step 2: Run it — expect FAIL.** Test Runner ▸ EditMode ▸ `EnvKeyOverrideTests`. Expected: red — `EnvKeyOverride` does not exist, EditMode assembly will not compile. + +- [ ] **Step 3: Add the interface.** Create `ISecureKeyStore.cs`: + ```csharp + namespace MCPForUnity.Editor.Security + { + /// + /// At-rest provider-key storage. Implementations persist into the OS secure store + /// (Keychain / Credential Manager / libsecret) or the AES-256-GCM file fallback. + /// Keys are read into a local only at the moment of an HTTP call and never serialized + /// into job records, logs, or anything that crosses the MCP bridge. + /// + public interface ISecureKeyStore + { + bool TryGet(string providerId, out string apiKey); + void Set(string providerId, string apiKey); + void Delete(string providerId); + bool Has(string providerId); // existence only; never returns the value + } + } + ``` + +- [ ] **Step 4: Add the shared constants.** Create `SecureKeyStoreConstants.cs`: + ```csharp + namespace MCPForUnity.Editor.Security + { + internal static class SecureKeyStoreConstants + { + /// Service/account namespace under which every provider key is stored. + public const string ServiceName = "MCPForUnity.AssetGen"; + + /// Canonical provider ids (lowercase). Used by the redactor and the provider registry. + public static readonly string[] ProviderIds = + { "tripo", "meshy", "hunyuan", "sketchfab", "fal", "openrouter" }; + } + } + ``` + +- [ ] **Step 5: Add the env override.** Create `EnvKeyOverride.cs`: + ```csharp + using System; + using System.Text; + + namespace MCPForUnity.Editor.Security + { + /// + /// Read-only environment-variable override for provider keys. Resolution order across + /// every key store is always: env -> persisted store. Variable name is + /// MCPFORUNITY_<PROVIDER>_API_KEY (provider upper-cased, each non-alphanumeric + /// char folded to '_'). Never persisted, never written back. + /// + internal static class EnvKeyOverride + { + public static string EnvVarName(string providerId) + { + var sb = new StringBuilder("MCPFORUNITY_"); + foreach (char c in (providerId ?? string.Empty).ToUpperInvariant()) + sb.Append(char.IsLetterOrDigit(c) ? c : '_'); + sb.Append("_API_KEY"); + return sb.ToString(); + } + + public static bool TryGet(string providerId, out string apiKey) + { + apiKey = null; + if (string.IsNullOrWhiteSpace(providerId)) return false; + string val = Environment.GetEnvironmentVariable(EnvVarName(providerId)); + if (string.IsNullOrEmpty(val)) return false; + apiKey = val; + return true; + } + } + } + ``` + +- [ ] **Step 6: Run it — expect PASS.** Test Runner ▸ EditMode ▸ `EnvKeyOverrideTests` → all 3 green. + +- [ ] **Step 7: Compile across the matrix.** Run `tools/check-unity-versions.sh` (no `#if` here, but confirms the new files compile on every editor). Expected: green. + +- [ ] **Step 8: Commit.** + ```bash + git add MCPForUnity/Editor/Security \ + TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen + git commit -m "feat(asset-gen): add ISecureKeyStore contract, constants, and env-var override + +Adds the ISecureKeyStore interface, the shared service-name/provider-id constants, +and the read-only MCPFORUNITY__API_KEY env override (env -> store). + +Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv" + ``` + +--- + +### Task 1.2: EncryptedFileKeyStore (AES-256-GCM fallback) + +**Files:** +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs` +- Test: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/EncryptedFileKeyStoreTests.cs` + +**Interfaces:** +- Consumes: `EnvKeyOverride.TryGet`, `SecureKeyStoreConstants` (none directly here, but same namespace). +- Produces: `public sealed class EncryptedFileKeyStore : ISecureKeyStore` with `public EncryptedFileKeyStore()` and a deterministic test ctor `public EncryptedFileKeyStore(string baseDir, string machineId)`. + +**Steps:** + +- [ ] **Step 1: Write the failing round-trip test.** Create `EncryptedFileKeyStoreTests.cs`: + ```csharp + using System.IO; + using NUnit.Framework; + using MCPForUnity.Editor.Security; + + namespace MCPForUnity.Editor.Tests.EditMode.AssetGen + { + [TestFixture] + public class EncryptedFileKeyStoreTests + { + private string _dir; + private EncryptedFileKeyStore _store; + + [SetUp] + public void SetUp() + { + _dir = Path.Combine(Path.GetTempPath(), "mcp_keystore_" + System.Guid.NewGuid().ToString("N")); + _store = new EncryptedFileKeyStore(_dir, "test-machine-id"); + } + + [TearDown] + public void TearDown() + { + System.Environment.SetEnvironmentVariable("MCPFORUNITY_TRIPO_API_KEY", null); + if (Directory.Exists(_dir)) Directory.Delete(_dir, true); + } + + [Test] + public void SetGetDeleteHas_RoundTrips() + { + Assert.IsFalse(_store.Has("tripo")); + Assert.IsFalse(_store.TryGet("tripo", out _)); + + _store.Set("tripo", "tsk_secret_value_abc123"); + Assert.IsTrue(_store.Has("tripo")); + Assert.IsTrue(_store.TryGet("tripo", out var got)); + Assert.AreEqual("tsk_secret_value_abc123", got); + + _store.Delete("tripo"); + Assert.IsFalse(_store.Has("tripo")); + Assert.IsFalse(_store.TryGet("tripo", out _)); + } + + [Test] + public void Ciphertext_OnDisk_DoesNotContainPlaintext() + { + _store.Set("tripo", "tsk_secret_value_abc123"); + string raw = File.ReadAllText(Path.Combine(_dir, "keystore.json")); + StringAssert.DoesNotContain("tsk_secret_value_abc123", raw); + } + + [Test] + public void EnvOverride_TakesPrecedence_OverStoredValue() + { + _store.Set("tripo", "stored_value"); + System.Environment.SetEnvironmentVariable("MCPFORUNITY_TRIPO_API_KEY", "env_value"); + Assert.IsTrue(_store.TryGet("tripo", out var got)); + Assert.AreEqual("env_value", got); + Assert.IsTrue(_store.Has("tripo")); + } + + [Test] + public void MultiSecretJsonBlob_RoundTrips_ForHunyuan() + { + string blob = "{\"secretId\":\"AKIDxxxxxxxx\",\"secretKey\":\"yyyyyyyyzzzz\"}"; + _store.Set("hunyuan", blob); + Assert.IsTrue(_store.TryGet("hunyuan", out var got)); + Assert.AreEqual(blob, got); + } + + [Test] + public void NewInstance_SameDirAndMachineId_DecryptsExistingEntry() + { + _store.Set("meshy", "msy_persisted_key_value"); + var reopened = new EncryptedFileKeyStore(_dir, "test-machine-id"); + Assert.IsTrue(reopened.TryGet("meshy", out var got)); + Assert.AreEqual("msy_persisted_key_value", got); + } + + [Test] + public void Set_NullKey_Throws() + { + Assert.Throws(() => _store.Set("tripo", null)); + } + } + } + ``` + +- [ ] **Step 2: Run it — expect FAIL.** Test Runner ▸ EditMode ▸ `EncryptedFileKeyStoreTests`. Expected: red — `EncryptedFileKeyStore` does not exist. + +- [ ] **Step 3: Implement the store.** Create `EncryptedFileKeyStore.cs` (complete; AES-256-GCM, PBKDF2-derived key bound to a per-user random salt + machine id, ciphertext under user app-data, `chmod 600`): + ```csharp + using System; + using System.Collections.Generic; + using System.IO; + using System.Runtime.InteropServices; + using System.Security.Cryptography; + using System.Text; + using Newtonsoft.Json; + + namespace MCPForUnity.Editor.Security + { + /// + /// AES-256-GCM file fallback used when no OS secure store is available. The data key is + /// PBKDF2(SHA-256, "<machineId>:<user>", perUserRandomSalt). The 32-byte salt is + /// generated once and stored next to the ciphertext under the user app-data dir + /// (never under Assets/ or the repo), with files hardened to 0600 on Unix. Documented + /// as weaker than an OS store. The env override still wins at get-time. + /// + public sealed class EncryptedFileKeyStore : ISecureKeyStore + { + private const int SaltBytes = 32; + private const int NonceBytes = 12; + private const int TagBytes = 16; + private const int Pbkdf2Iterations = 100_000; + + private readonly object _gate = new(); + private readonly string _baseDir; + private readonly string _saltPath; + private readonly string _storePath; + private readonly string _machineId; + private byte[] _cachedKey; + + public EncryptedFileKeyStore() : this(DefaultBaseDir(), DefaultMachineId()) { } + + // Deterministic ctor for tests (explicit dir + machine id => CI-safe). + public EncryptedFileKeyStore(string baseDir, string machineId) + { + _baseDir = baseDir; + _machineId = string.IsNullOrEmpty(machineId) ? "unknown-machine" : machineId; + _saltPath = Path.Combine(_baseDir, "keystore.salt"); + _storePath = Path.Combine(_baseDir, "keystore.json"); + } + + private static string DefaultBaseDir() + { + string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (string.IsNullOrEmpty(appData)) + appData = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config"); + return Path.Combine(appData, "MCPForUnity", "AssetGen", "keystore"); + } + + private static string DefaultMachineId() => + Environment.MachineName + ":" + Environment.OSVersion.Platform; + + public bool TryGet(string providerId, out string apiKey) + { + // env override always wins, read-only. + if (EnvKeyOverride.TryGet(providerId, out apiKey)) return true; + apiKey = null; + if (string.IsNullOrWhiteSpace(providerId)) return false; + lock (_gate) + { + var store = ReadStore(); + if (!store.TryGetValue(providerId, out var b64) || string.IsNullOrEmpty(b64)) + return false; + try + { + apiKey = Decrypt(Convert.FromBase64String(b64)); + return true; + } + catch + { + return false; // corrupt / undecryptable entry (e.g. machine changed) + } + } + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrWhiteSpace(providerId)) + throw new ArgumentException("providerId is required", nameof(providerId)); + if (apiKey == null) + throw new ArgumentNullException(nameof(apiKey)); + lock (_gate) + { + var store = ReadStore(); + store[providerId] = Convert.ToBase64String(Encrypt(apiKey)); + WriteStore(store); + } + } + + public void Delete(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) return; + lock (_gate) + { + var store = ReadStore(); + if (store.Remove(providerId)) + WriteStore(store); + } + } + + public bool Has(string providerId) + { + if (EnvKeyOverride.TryGet(providerId, out _)) return true; + if (string.IsNullOrWhiteSpace(providerId)) return false; + lock (_gate) + { + return ReadStore().ContainsKey(providerId); + } + } + + // ---- crypto ---- + private byte[] Encrypt(string plaintext) + { + byte[] key = GetKey(); + byte[] pt = Encoding.UTF8.GetBytes(plaintext); + byte[] nonce = new byte[NonceBytes]; + using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(nonce); + byte[] ct = new byte[pt.Length]; + byte[] tag = new byte[TagBytes]; + try + { + using var gcm = new AesGcm(key); + gcm.Encrypt(nonce, pt, ct, tag); + } + catch (PlatformNotSupportedException ex) + { + throw new InvalidOperationException( + "AES-GCM is unavailable in this runtime. Use an OS secure store, or set the " + + "MCPFORUNITY__API_KEY environment variable.", ex); + } + // layout: nonce(12) | tag(16) | ciphertext + byte[] blob = new byte[NonceBytes + TagBytes + ct.Length]; + Buffer.BlockCopy(nonce, 0, blob, 0, NonceBytes); + Buffer.BlockCopy(tag, 0, blob, NonceBytes, TagBytes); + Buffer.BlockCopy(ct, 0, blob, NonceBytes + TagBytes, ct.Length); + return blob; + } + + private string Decrypt(byte[] blob) + { + if (blob == null || blob.Length < NonceBytes + TagBytes) + throw new CryptographicException("Ciphertext too short."); + byte[] key = GetKey(); + byte[] nonce = new byte[NonceBytes]; + byte[] tag = new byte[TagBytes]; + byte[] ct = new byte[blob.Length - NonceBytes - TagBytes]; + Buffer.BlockCopy(blob, 0, nonce, 0, NonceBytes); + Buffer.BlockCopy(blob, NonceBytes, tag, 0, TagBytes); + Buffer.BlockCopy(blob, NonceBytes + TagBytes, ct, 0, ct.Length); + byte[] pt = new byte[ct.Length]; + using var gcm = new AesGcm(key); + gcm.Decrypt(nonce, ct, tag, pt); + return Encoding.UTF8.GetString(pt); + } + + private byte[] GetKey() + { + if (_cachedKey != null) return _cachedKey; + byte[] salt = LoadOrCreateSalt(); + byte[] pw = Encoding.UTF8.GetBytes(_machineId + ":" + Environment.UserName); + using var kdf = new Rfc2898DeriveBytes(pw, salt, Pbkdf2Iterations, HashAlgorithmName.SHA256); + _cachedKey = kdf.GetBytes(32); + return _cachedKey; + } + + private byte[] LoadOrCreateSalt() + { + Directory.CreateDirectory(_baseDir); + HardenDir(_baseDir); + if (File.Exists(_saltPath)) + { + byte[] existing = File.ReadAllBytes(_saltPath); + if (existing.Length == SaltBytes) return existing; + } + byte[] salt = new byte[SaltBytes]; + using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(salt); + File.WriteAllBytes(_saltPath, salt); + HardenFile(_saltPath); + return salt; + } + + private Dictionary ReadStore() + { + if (!File.Exists(_storePath)) return new Dictionary(); + try + { + string json = File.ReadAllText(_storePath); + return JsonConvert.DeserializeObject>(json) + ?? new Dictionary(); + } + catch + { + return new Dictionary(); + } + } + + private void WriteStore(Dictionary store) + { + Directory.CreateDirectory(_baseDir); + HardenDir(_baseDir); + File.WriteAllText(_storePath, JsonConvert.SerializeObject(store)); + HardenFile(_storePath); + } + + // ---- unix perms (no-op on Windows; NTFS user-profile ACL applies) ---- + private static void HardenFile(string path) => Chmod(path, "600"); + private static void HardenDir(string path) => Chmod(path, "700"); + + private static void Chmod(string path, string mode) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return; + try + { + var psi = new System.Diagnostics.ProcessStartInfo("/bin/chmod") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardError = true + }; + psi.ArgumentList.Add(mode); + psi.ArgumentList.Add(path); + using var p = System.Diagnostics.Process.Start(psi); + p?.WaitForExit(2000); + } + catch { /* best-effort hardening */ } + } + } + } + ``` + +- [ ] **Step 4: Run it — expect PASS.** Test Runner ▸ EditMode ▸ `EncryptedFileKeyStoreTests` → all 6 green. (`Ciphertext_OnDisk_DoesNotContainPlaintext` proves at-rest encryption; `EnvOverride_TakesPrecedence` proves env→store ordering; `MultiSecretJsonBlob_RoundTrips_ForHunyuan` proves the Hunyuan JSON blob survives intact.) + +- [ ] **Step 5: Compile across the matrix.** `tools/check-unity-versions.sh`. Expected: green on every editor (confirms `AesGcm`/`Rfc2898DeriveBytes`/`ArgumentList` resolve at the project's NS2.1 API level). + +- [ ] **Step 6: Commit.** + ```bash + git add MCPForUnity/Editor/Security/SecureKeyStore/EncryptedFileKeyStore.cs* \ + TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/EncryptedFileKeyStoreTests.cs* + git commit -m "feat(asset-gen): add AES-256-GCM EncryptedFileKeyStore fallback + +PBKDF2(SHA-256)-derived key bound to a per-user random salt + machine id; ciphertext +under the user app-data dir (0600), never under Assets/. Env override wins at get-time. +Round-trip, at-rest-encryption, env-precedence, and Hunyuan-JSON-blob tests pass. + +Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv" + ``` + +--- + +### Task 1.3: macOS Keychain key store + +**Files:** +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs` +- Test: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MacKeychainKeyStoreTests.cs` + +**Interfaces:** +- Consumes: `SecureKeyStoreConstants.ServiceName`, `EnvKeyOverride.TryGet`. +- Produces: `public sealed class MacKeychainKeyStore : ISecureKeyStore` + `static bool IsAvailable()`. + +**Steps:** + +- [ ] **Step 1: Write the explicit OS round-trip test.** Create `MacKeychainKeyStoreTests.cs`. It touches the real Keychain so it is `[Explicit]` (skipped in CI / on other platforms; run manually on a macOS editor): + ```csharp + using NUnit.Framework; + using MCPForUnity.Editor.Security; + + namespace MCPForUnity.Editor.Tests.EditMode.AssetGen + { + // Writes to the real macOS Keychain. [Explicit] => not run in CI; run manually on macOS. + [TestFixture] + [Explicit("Writes to the real macOS Keychain; run manually on macOS.")] + public class MacKeychainKeyStoreTests + { + private const string Provider = "mcp_test_provider"; + private MacKeychainKeyStore _store; + + [SetUp] + public void SetUp() + { + if (!MacKeychainKeyStore.IsAvailable()) Assert.Ignore("/usr/bin/security not present"); + _store = new MacKeychainKeyStore(); + _store.Delete(Provider); + } + + [TearDown] + public void TearDown() => _store?.Delete(Provider); + + [Test] + public void SetGetDeleteHas_RoundTrips() + { + Assert.IsFalse(_store.Has(Provider)); + _store.Set(Provider, "tsk_keychain_roundtrip_1"); + Assert.IsTrue(_store.Has(Provider)); + Assert.IsTrue(_store.TryGet(Provider, out var got)); + Assert.AreEqual("tsk_keychain_roundtrip_1", got); + _store.Delete(Provider); + Assert.IsFalse(_store.Has(Provider)); + } + } + } + ``` + +- [ ] **Step 2: Run it — expect FAIL.** Test Runner ▸ EditMode. Expected: red — `MacKeychainKeyStore` does not exist, assembly will not compile. + +- [ ] **Step 3: Implement the store.** Create `MacKeychainKeyStore.cs` (complete; `/usr/bin/security` generic passwords via `System.Diagnostics.Process`, args via `ArgumentList` to avoid quoting/injection): + ```csharp + using System; + using System.Diagnostics; + using System.IO; + + namespace MCPForUnity.Editor.Security + { + /// + /// macOS Keychain generic-password store via /usr/bin/security. Service = + /// MCPForUnity.AssetGen, account = providerId. Note: Set passes the key as a -w + /// argument, briefly visible to a same-user `ps`; this is inside the documented + /// same-OS-user threat boundary and still far stronger than plaintext EditorPrefs. + /// + public sealed class MacKeychainKeyStore : ISecureKeyStore + { + private const string SecurityBin = "/usr/bin/security"; + + public static bool IsAvailable() => File.Exists(SecurityBin); + + public bool TryGet(string providerId, out string apiKey) + { + if (EnvKeyOverride.TryGet(providerId, out apiKey)) return true; + apiKey = null; + if (string.IsNullOrWhiteSpace(providerId)) return false; + int code = Run(out string stdout, out _, + "find-generic-password", "-s", SecureKeyStoreConstants.ServiceName, + "-a", providerId, "-w"); + if (code != 0) return false; // 44 = errSecItemNotFound + apiKey = stdout.TrimEnd('\n', '\r'); + return true; + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrWhiteSpace(providerId)) + throw new ArgumentException("providerId is required", nameof(providerId)); + if (apiKey == null) throw new ArgumentNullException(nameof(apiKey)); + // -U updates the item in place if it already exists. + int code = Run(out _, out string stderr, + "add-generic-password", "-U", "-s", SecureKeyStoreConstants.ServiceName, + "-a", providerId, "-w", apiKey); + if (code != 0) + throw new InvalidOperationException( + $"Keychain add-generic-password failed (exit {code}): {stderr}"); + } + + public void Delete(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) return; + Run(out _, out _, "delete-generic-password", + "-s", SecureKeyStoreConstants.ServiceName, "-a", providerId); + } + + public bool Has(string providerId) + { + if (EnvKeyOverride.TryGet(providerId, out _)) return true; + if (string.IsNullOrWhiteSpace(providerId)) return false; + // No -w => password is not emitted; exit 0 means the item exists. + return Run(out _, out _, "find-generic-password", + "-s", SecureKeyStoreConstants.ServiceName, "-a", providerId) == 0; + } + + private static int Run(out string stdout, out string stderr, params string[] args) + { + var psi = new ProcessStartInfo(SecurityBin) + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = Process.Start(psi); + stdout = p.StandardOutput.ReadToEnd(); + stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(5000); + return p.HasExited ? p.ExitCode : -1; + } + } + } + ``` + +- [ ] **Step 4: Verify.** Compile-only here (CI cannot exercise the Keychain). On a macOS editor, optionally run Test Runner ▸ EditMode ▸ `MacKeychainKeyStoreTests` with "Run Explicit" enabled → green. Then `tools/check-unity-versions.sh` → green (class compiles on all platforms; `Process` calls are inert off-macOS). + +- [ ] **Step 5: Commit.** + ```bash + git add MCPForUnity/Editor/Security/SecureKeyStore/MacKeychainKeyStore.cs* \ + TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/MacKeychainKeyStoreTests.cs* + git commit -m "feat(asset-gen): add macOS Keychain key store + +Generic-password store over /usr/bin/security (service MCPForUnity.AssetGen, account +providerId) with env override at get-time. Explicit round-trip test runs manually on macOS. + +Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv" + ``` + +--- + +### Task 1.4: Windows Credential Manager key store + +**Files:** +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs` +- Test: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/WindowsCredentialKeyStoreTests.cs` + +**Interfaces:** +- Consumes: `SecureKeyStoreConstants.ServiceName`, `EnvKeyOverride.TryGet`. +- Produces: `public sealed class WindowsCredentialKeyStore : ISecureKeyStore` (advapi32 P/Invoke `CredWrite`/`CredRead`/`CredDelete`/`CredFree`, `CRED_TYPE_GENERIC`). + +**Steps:** + +- [ ] **Step 1: Write the explicit OS round-trip test.** Create `WindowsCredentialKeyStoreTests.cs`: + ```csharp + using NUnit.Framework; + using MCPForUnity.Editor.Security; + + namespace MCPForUnity.Editor.Tests.EditMode.AssetGen + { + // Writes to the real Windows Credential Manager. [Explicit] => not run in CI; run manually on Windows. + [TestFixture] + [Explicit("Writes to the real Windows Credential Manager; run manually on Windows.")] + public class WindowsCredentialKeyStoreTests + { + private const string Provider = "mcp_test_provider"; + private WindowsCredentialKeyStore _store; + + [SetUp] + public void SetUp() + { + if (System.Environment.OSVersion.Platform != System.PlatformID.Win32NT) + Assert.Ignore("Not running on Windows"); + _store = new WindowsCredentialKeyStore(); + _store.Delete(Provider); + } + + [TearDown] + public void TearDown() => _store?.Delete(Provider); + + [Test] + public void SetGetDeleteHas_RoundTrips() + { + Assert.IsFalse(_store.Has(Provider)); + _store.Set(Provider, "tsk_credman_roundtrip_1"); + Assert.IsTrue(_store.Has(Provider)); + Assert.IsTrue(_store.TryGet(Provider, out var got)); + Assert.AreEqual("tsk_credman_roundtrip_1", got); + _store.Delete(Provider); + Assert.IsFalse(_store.Has(Provider)); + } + } + } + ``` + +- [ ] **Step 2: Run it — expect FAIL.** Test Runner ▸ EditMode. Expected: red — `WindowsCredentialKeyStore` does not exist. + +- [ ] **Step 3: Implement the store.** Create `WindowsCredentialKeyStore.cs` (complete P/Invoke; compiles on all platforms because DllImport binds lazily — only instantiated on Windows by the factory): + ```csharp + using System; + using System.Runtime.InteropServices; + using System.Text; + + namespace MCPForUnity.Editor.Security + { + /// + /// Windows Credential Manager generic-credential store via advapi32. Target = + /// "MCPForUnity.AssetGen:<provider>", DPAPI-protected by the OS for the current user. + /// Env override wins at get-time. + /// + public sealed class WindowsCredentialKeyStore : ISecureKeyStore + { + private const uint CRED_TYPE_GENERIC = 1; + private const uint CRED_PERSIST_LOCAL_MACHINE = 2; + + private static string Target(string providerId) => + SecureKeyStoreConstants.ServiceName + ":" + providerId; + + public bool TryGet(string providerId, out string apiKey) + { + if (EnvKeyOverride.TryGet(providerId, out apiKey)) return true; + apiKey = null; + if (string.IsNullOrWhiteSpace(providerId)) return false; + if (!CredRead(Target(providerId), CRED_TYPE_GENERIC, 0, out IntPtr credPtr)) + return false; + try + { + var cred = Marshal.PtrToStructure(credPtr); + if (cred.CredentialBlob == IntPtr.Zero || cred.CredentialBlobSize == 0) + { + apiKey = string.Empty; + return true; + } + byte[] data = new byte[cred.CredentialBlobSize]; + Marshal.Copy(cred.CredentialBlob, data, 0, (int)cred.CredentialBlobSize); + apiKey = Encoding.UTF8.GetString(data); + return true; + } + finally + { + CredFree(credPtr); + } + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrWhiteSpace(providerId)) + throw new ArgumentException("providerId is required", nameof(providerId)); + if (apiKey == null) throw new ArgumentNullException(nameof(apiKey)); + byte[] blob = Encoding.UTF8.GetBytes(apiKey); + IntPtr blobPtr = Marshal.AllocHGlobal(blob.Length == 0 ? 1 : blob.Length); + try + { + if (blob.Length > 0) Marshal.Copy(blob, 0, blobPtr, blob.Length); + var cred = new CREDENTIAL + { + Type = CRED_TYPE_GENERIC, + TargetName = Target(providerId), + CredentialBlobSize = (uint)blob.Length, + CredentialBlob = blobPtr, + Persist = CRED_PERSIST_LOCAL_MACHINE, + UserName = providerId + }; + if (!CredWrite(ref cred, 0)) + throw new InvalidOperationException( + $"CredWrite failed: Win32 error {Marshal.GetLastWin32Error()}"); + } + finally + { + Marshal.FreeHGlobal(blobPtr); + } + } + + public void Delete(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) return; + CredDelete(Target(providerId), CRED_TYPE_GENERIC, 0); + } + + public bool Has(string providerId) + { + if (EnvKeyOverride.TryGet(providerId, out _)) return true; + if (string.IsNullOrWhiteSpace(providerId)) return false; + if (!CredRead(Target(providerId), CRED_TYPE_GENERIC, 0, out IntPtr credPtr)) + return false; + CredFree(credPtr); + return true; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct CREDENTIAL + { + public uint Flags; + public uint Type; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetName; + [MarshalAs(UnmanagedType.LPWStr)] public string Comment; + public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten; + public uint CredentialBlobSize; + public IntPtr CredentialBlob; + public uint Persist; + public uint AttributeCount; + public IntPtr Attributes; + [MarshalAs(UnmanagedType.LPWStr)] public string TargetAlias; + [MarshalAs(UnmanagedType.LPWStr)] public string UserName; + } + + [DllImport("advapi32.dll", EntryPoint = "CredWriteW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredWrite(ref CREDENTIAL credential, uint flags); + + [DllImport("advapi32.dll", EntryPoint = "CredReadW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredRead(string target, uint type, uint reservedFlag, out IntPtr credentialPtr); + + [DllImport("advapi32.dll", EntryPoint = "CredDeleteW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CredDelete(string target, uint type, uint flags); + + [DllImport("advapi32.dll", EntryPoint = "CredFree")] + private static extern void CredFree(IntPtr buffer); + } + } + ``` + +- [ ] **Step 4: Verify.** `tools/check-unity-versions.sh` → green (the file compiles on macOS/Linux editors too; advapi32 only resolves when called on Windows). On a Windows editor, optionally run `WindowsCredentialKeyStoreTests` with Explicit enabled → green. + +- [ ] **Step 5: Commit.** + ```bash + git add MCPForUnity/Editor/Security/SecureKeyStore/WindowsCredentialKeyStore.cs* \ + TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/WindowsCredentialKeyStoreTests.cs* + git commit -m "feat(asset-gen): add Windows Credential Manager key store + +advapi32 CredWrite/CredRead/CredDelete generic credentials (target MCPForUnity.AssetGen:), +DPAPI-backed, with env override at get-time. Explicit round-trip test runs manually on Windows. + +Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv" + ``` + +--- + +### Task 1.5: Linux secret-tool key store + +**Files:** +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs` +- Test: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/LinuxSecretToolKeyStoreTests.cs` + +**Interfaces:** +- Consumes: `SecureKeyStoreConstants.ServiceName`, `EnvKeyOverride.TryGet`. +- Produces: `public sealed class LinuxSecretToolKeyStore : ISecureKeyStore` + `static bool IsAvailable()`. + +**Steps:** + +- [ ] **Step 1: Write the explicit OS round-trip test.** Create `LinuxSecretToolKeyStoreTests.cs`: + ```csharp + using NUnit.Framework; + using MCPForUnity.Editor.Security; + + namespace MCPForUnity.Editor.Tests.EditMode.AssetGen + { + // Writes to the real libsecret store via secret-tool. [Explicit] => not run in CI; run manually on Linux. + [TestFixture] + [Explicit("Writes to the real libsecret store; run manually on Linux with secret-tool + an unlocked keyring.")] + public class LinuxSecretToolKeyStoreTests + { + private const string Provider = "mcp_test_provider"; + private LinuxSecretToolKeyStore _store; + + [SetUp] + public void SetUp() + { + if (!LinuxSecretToolKeyStore.IsAvailable()) Assert.Ignore("secret-tool not present"); + _store = new LinuxSecretToolKeyStore(); + _store.Delete(Provider); + } + + [TearDown] + public void TearDown() => _store?.Delete(Provider); + + [Test] + public void SetGetDeleteHas_RoundTrips() + { + Assert.IsFalse(_store.Has(Provider)); + _store.Set(Provider, "tsk_secrettool_roundtrip_1"); + Assert.IsTrue(_store.Has(Provider)); + Assert.IsTrue(_store.TryGet(Provider, out var got)); + Assert.AreEqual("tsk_secrettool_roundtrip_1", got); + _store.Delete(Provider); + Assert.IsFalse(_store.Has(Provider)); + } + } + } + ``` + +- [ ] **Step 2: Run it — expect FAIL.** Test Runner ▸ EditMode. Expected: red — `LinuxSecretToolKeyStore` does not exist. + +- [ ] **Step 3: Implement the store.** Create `LinuxSecretToolKeyStore.cs` (complete; the secret is passed on **stdin**, never in process args): + ```csharp + using System; + using System.Diagnostics; + + namespace MCPForUnity.Editor.Security + { + /// + /// Linux libsecret store via the `secret-tool` CLI. Attributes: + /// service=MCPForUnity.AssetGen, account=providerId. The secret is written to stdin + /// (never visible in process args). Env override wins at get-time. + /// + public sealed class LinuxSecretToolKeyStore : ISecureKeyStore + { + public static bool IsAvailable() + { + try + { + return Run(null, out _, out _, "--version") == 0; + } + catch + { + return false; + } + } + + public bool TryGet(string providerId, out string apiKey) + { + if (EnvKeyOverride.TryGet(providerId, out apiKey)) return true; + apiKey = null; + if (string.IsNullOrWhiteSpace(providerId)) return false; + int code = Run(null, out string stdout, out _, + "lookup", "service", SecureKeyStoreConstants.ServiceName, "account", providerId); + if (code != 0) return false; // 1 = not found + apiKey = stdout.TrimEnd('\n', '\r'); + return true; + } + + public void Set(string providerId, string apiKey) + { + if (string.IsNullOrWhiteSpace(providerId)) + throw new ArgumentException("providerId is required", nameof(providerId)); + if (apiKey == null) throw new ArgumentNullException(nameof(apiKey)); + int code = Run(apiKey, out _, out string stderr, + "store", "--label", SecureKeyStoreConstants.ServiceName + " " + providerId, + "service", SecureKeyStoreConstants.ServiceName, "account", providerId); + if (code != 0) + throw new InvalidOperationException( + $"secret-tool store failed (exit {code}): {stderr}"); + } + + public void Delete(string providerId) + { + if (string.IsNullOrWhiteSpace(providerId)) return; + Run(null, out _, out _, + "clear", "service", SecureKeyStoreConstants.ServiceName, "account", providerId); + } + + public bool Has(string providerId) + { + if (EnvKeyOverride.TryGet(providerId, out _)) return true; + if (string.IsNullOrWhiteSpace(providerId)) return false; + return Run(null, out _, out _, + "lookup", "service", SecureKeyStoreConstants.ServiceName, "account", providerId) == 0; + } + + private static int Run(string stdin, out string stdout, out string stderr, params string[] args) + { + var psi = new ProcessStartInfo("secret-tool") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = stdin != null, + RedirectStandardOutput = true, + RedirectStandardError = true + }; + foreach (var a in args) psi.ArgumentList.Add(a); + using var p = Process.Start(psi); + if (stdin != null) + { + p.StandardInput.Write(stdin); + p.StandardInput.Close(); + } + stdout = p.StandardOutput.ReadToEnd(); + stderr = p.StandardError.ReadToEnd(); + p.WaitForExit(5000); + return p.HasExited ? p.ExitCode : -1; + } + } + } + ``` + +- [ ] **Step 4: Verify.** `tools/check-unity-versions.sh` → green. On a Linux editor with `secret-tool` + an unlocked keyring, optionally run `LinuxSecretToolKeyStoreTests` with Explicit enabled → green. + +- [ ] **Step 5: Commit.** + ```bash + git add MCPForUnity/Editor/Security/SecureKeyStore/LinuxSecretToolKeyStore.cs* \ + TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/LinuxSecretToolKeyStoreTests.cs* + git commit -m "feat(asset-gen): add Linux secret-tool key store + +libsecret store via secret-tool (service MCPForUnity.AssetGen, account providerId); the +secret is passed on stdin, never in argv. Env override at get-time. Explicit test on Linux. + +Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv" + ``` + +--- + +### Task 1.6: Platform-selecting SecureKeyStore factory + +**Files:** +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs` +- Test: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SecureKeyStoreFactoryTests.cs` + +**Interfaces:** +- Consumes: `MacKeychainKeyStore`, `WindowsCredentialKeyStore`, `LinuxSecretToolKeyStore`, `EncryptedFileKeyStore`, `McpLog.Warn`. +- Produces: `public static class SecureKeyStore { public static ISecureKeyStore Current { get; } }` + internal `SetForTesting`/`ResetForTesting` seams. + +**Steps:** + +- [ ] **Step 1: Write the failing factory test.** Create `SecureKeyStoreFactoryTests.cs`: + ```csharp + using NUnit.Framework; + using MCPForUnity.Editor.Security; + + namespace MCPForUnity.Editor.Tests.EditMode.AssetGen + { + [TestFixture] + public class SecureKeyStoreFactoryTests + { + [TearDown] + public void TearDown() => SecureKeyStore.ResetForTesting(); + + [Test] + public void Current_IsNeverNull() + { + Assert.IsNotNull(SecureKeyStore.Current); + } + + [Test] + public void SetForTesting_OverridesCurrent_AndResetRestores() + { + var dir = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), "mcp_factory_" + System.Guid.NewGuid().ToString("N")); + var fake = new EncryptedFileKeyStore(dir, "test-machine-id"); + SecureKeyStore.SetForTesting(fake); + Assert.AreSame(fake, SecureKeyStore.Current); + SecureKeyStore.ResetForTesting(); + Assert.AreNotSame(fake, SecureKeyStore.Current); + } + } + } + ``` + +- [ ] **Step 2: Run it — expect FAIL.** Test Runner ▸ EditMode ▸ `SecureKeyStoreFactoryTests`. Expected: red — `SecureKeyStore` does not exist. + +- [ ] **Step 3: Implement the factory.** Create `SecureKeyStore.cs`. All four store types now exist (Tasks 1.2–1.5), so the `#if`-guarded branches compile on every platform: + ```csharp + using System; + using MCPForUnity.Editor.Helpers; + + namespace MCPForUnity.Editor.Security + { + /// + /// Platform-selecting factory for : + /// macOS -> Keychain, Windows -> Credential Manager, Linux -> secret-tool, otherwise the + /// AES-256-GCM fallback. The selected store is cached + /// for the editor session. There is intentionally no "read key" tool/action anywhere. + /// + public static class SecureKeyStore + { + private static readonly object Gate = new(); + private static ISecureKeyStore _current; + private static ISecureKeyStore _testOverride; + + public static ISecureKeyStore Current + { + get + { + if (_testOverride != null) return _testOverride; + if (_current != null) return _current; + lock (Gate) + { + _current ??= Create(); + return _current; + } + } + } + + private static ISecureKeyStore Create() + { + try + { + #if UNITY_EDITOR_OSX + if (MacKeychainKeyStore.IsAvailable()) return new MacKeychainKeyStore(); + #elif UNITY_EDITOR_WIN + return new WindowsCredentialKeyStore(); + #elif UNITY_EDITOR_LINUX + if (LinuxSecretToolKeyStore.IsAvailable()) return new LinuxSecretToolKeyStore(); + #endif + } + catch (Exception ex) + { + McpLog.Warn($"[SecureKeyStore] OS store unavailable, using encrypted file fallback: {ex.Message}"); + } + return new EncryptedFileKeyStore(); + } + + // ---- Test seams (MCPForUnityTests.EditMode has InternalsVisibleTo access) ---- + internal static void SetForTesting(ISecureKeyStore store) => _testOverride = store; + internal static void ResetForTesting() => _testOverride = null; + } + } + ``` + +- [ ] **Step 4: Run it — expect PASS.** Test Runner ▸ EditMode ▸ `SecureKeyStoreFactoryTests` → both green. + +- [ ] **Step 5: Compile across the matrix.** `tools/check-unity-versions.sh` — this is the first file with `#if UNITY_EDITOR_*` branches, so verify all branches compile on every editor in `tools/unity-versions.json`. Expected: green. + +- [ ] **Step 6: Commit.** + ```bash + git add MCPForUnity/Editor/Security/SecureKeyStore/SecureKeyStore.cs* \ + TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SecureKeyStoreFactoryTests.cs* + git commit -m "feat(asset-gen): add platform-selecting SecureKeyStore factory + +SecureKeyStore.Current picks Keychain/CredMan/secret-tool by platform and falls back to the +AES-256-GCM file store; selection is cached. Adds internal SetForTesting/ResetForTesting seams. + +Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv" + ``` + +--- + +### Task 1.7: SecretRedactor (log/error scrubbing) + +**Files:** +- Create: `MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs` +- Test: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SecretRedactorTests.cs` + +**Interfaces:** +- Consumes: `SecureKeyStore.Current.TryGet`, `SecureKeyStoreConstants.ProviderIds`. +- Produces: `public static class SecretRedactor { public static string Scrub(string text); }` — strips known stored key values (incl. Hunyuan inner JSON secrets) plus bearer/token/key auth headers and key-shaped literals. + +**Steps:** + +- [ ] **Step 1: Write the failing redactor test.** Create `SecretRedactorTests.cs`: + ```csharp + using System.IO; + using NUnit.Framework; + using MCPForUnity.Editor.Security; + + namespace MCPForUnity.Editor.Tests.EditMode.AssetGen + { + [TestFixture] + public class SecretRedactorTests + { + private string _dir; + + [SetUp] + public void SetUp() + { + _dir = Path.Combine(Path.GetTempPath(), "mcp_redactor_" + System.Guid.NewGuid().ToString("N")); + SecureKeyStore.SetForTesting(new EncryptedFileKeyStore(_dir, "test-machine-id")); + } + + [TearDown] + public void TearDown() + { + SecureKeyStore.ResetForTesting(); + if (Directory.Exists(_dir)) Directory.Delete(_dir, true); + } + + [Test] + public void Scrub_StripsStoredKeyValue() + { + SecureKeyStore.Current.Set("tripo", "tsk_super_secret_value_42"); + string redacted = SecretRedactor.Scrub("request failed with key tsk_super_secret_value_42 attached"); + StringAssert.DoesNotContain("tsk_super_secret_value_42", redacted); + StringAssert.Contains("***REDACTED***", redacted); + } + + [Test] + public void Scrub_StripsHunyuanInnerSecrets() + { + SecureKeyStore.Current.Set("hunyuan", + "{\"secretId\":\"AKIDexample01\",\"secretKey\":\"superSecretKey99\"}"); + string redacted = SecretRedactor.Scrub("signing with AKIDexample01 and superSecretKey99 now"); + StringAssert.DoesNotContain("AKIDexample01", redacted); + StringAssert.DoesNotContain("superSecretKey99", redacted); + } + + [Test] + public void Scrub_StripsBearerToken_EvenWhenNotStored() + { + string redacted = SecretRedactor.Scrub("Authorization: Bearer abcd1234efgh5678ijkl"); + StringAssert.DoesNotContain("abcd1234efgh5678ijkl", redacted); + StringAssert.Contains("***REDACTED***", redacted); + } + + [Test] + public void Scrub_NullOrEmpty_Passthrough() + { + Assert.IsNull(SecretRedactor.Scrub(null)); + Assert.AreEqual("", SecretRedactor.Scrub("")); + } + } + } + ``` + +- [ ] **Step 2: Run it — expect FAIL.** Test Runner ▸ EditMode ▸ `SecretRedactorTests`. Expected: red — `SecretRedactor` does not exist. + +- [ ] **Step 3: Implement the redactor.** Create `SecretRedactor.cs`: + ```csharp + using System.Collections.Generic; + using System.Text.RegularExpressions; + using Newtonsoft.Json.Linq; + + namespace MCPForUnity.Editor.Security + { + /// + /// Scrubs known provider key values and auth/bearer/token literals out of any text before + /// it is logged, surfaced in an error, or returned to an agent. Never throws. + /// + public static class SecretRedactor + { + private const string Mask = "***REDACTED***"; + + // Authorization: Bearer xxx / Authorization: Token xxx / Authorization: Key xxx + private static readonly Regex AuthHeaderRegex = new Regex( + @"(?i)(authorization\s*[:=]\s*)(bearer|token|key)\s+[A-Za-z0-9._\-]+", + RegexOptions.Compiled); + + // Common provider key shapes: tsk_ (Tripo), sk-/sk_ (OpenRouter etc.), msy_ (Meshy), key- (fal). + private static readonly Regex TokenLiteralRegex = new Regex( + @"(?i)\b(tsk_|sk-|sk_|msy_|key-)[A-Za-z0-9._\-]{8,}", + RegexOptions.Compiled); + + public static string Scrub(string text) + { + if (string.IsNullOrEmpty(text)) return text; + string result = text; + + // 1) Strip any concrete stored key values we know about. + foreach (var id in SecureKeyStoreConstants.ProviderIds) + { + try + { + if (!SecureKeyStore.Current.TryGet(id, out var key)) continue; + if (string.IsNullOrEmpty(key) || key.Length < 6) continue; + result = result.Replace(key, Mask); + // Hunyuan stores a JSON blob; redact its inner secret values too. + foreach (var inner in ExtractJsonStringValues(key)) + if (inner != null && inner.Length >= 6) result = result.Replace(inner, Mask); + } + catch + { + // redaction must never throw — fall through to pattern scrubbing + } + } + + // 2) Strip auth headers + common key-shaped literals. + result = AuthHeaderRegex.Replace(result, "$1$2 " + Mask); + result = TokenLiteralRegex.Replace(result, Mask); + return result; + } + + private static IEnumerable ExtractJsonStringValues(string maybeJson) + { + var values = new List(); + if (string.IsNullOrEmpty(maybeJson) || maybeJson[0] != '{') return values; + try + { + var obj = JObject.Parse(maybeJson); + foreach (var prop in obj.Properties()) + if (prop.Value.Type == JTokenType.String) + values.Add(prop.Value.Value()); + } + catch + { + // not JSON — nothing extra to extract + } + return values; + } + } + } + ``` + +- [ ] **Step 4: Run it — expect PASS.** Test Runner ▸ EditMode ▸ `SecretRedactorTests` → all 4 green. + +- [ ] **Step 5: Compile across the matrix.** `tools/check-unity-versions.sh` → green. + +- [ ] **Step 6: Commit.** + ```bash + git add MCPForUnity/Editor/Security/SecureKeyStore/SecretRedactor.cs* \ + TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/SecretRedactorTests.cs* + git commit -m "feat(asset-gen): add SecretRedactor for log/error scrubbing + +Scrubs stored provider keys (incl. Hunyuan inner JSON secrets) and bearer/token/key auth +literals from any text before it is logged or surfaced. Never throws. + +Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv" + ``` + +--- + +### Task 1.8: Guard — a serialized job record can never contain a key + +**Files:** +- Test: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobKeyLeakGuardTests.cs` + +**Interfaces:** +- Consumes: `SecureKeyStore.SetForTesting`/`Current.Set`, `SecretRedactor.Scrub`, `Newtonsoft.Json.JsonConvert`. +- Produces: a regression guard locking in the security invariant that the job-record shape carries no secret field, and that any accidental embedding in an error string is redacted. + +This task is test-only — it codifies the contract that `AssetGenJob` (built in the job-manager phase) has exactly the fields `{ JobId, Kind, Provider, Action, State, Progress, AssetPath, AssetGuid, Error }`, **none** of which is a key. The inline anonymous object below mirrors that field set so the guard runs now; when the real `AssetGenJob` type lands, swap the anonymous object for it and the assertions are unchanged. + +**Steps:** + +- [ ] **Step 1: Write the guard test.** Create `AssetGenJobKeyLeakGuardTests.cs`: + ```csharp + using System.IO; + using NUnit.Framework; + using Newtonsoft.Json; + using MCPForUnity.Editor.Security; + + namespace MCPForUnity.Editor.Tests.EditMode.AssetGen + { + /// + /// Locks in the security invariant that a serialized job record can never contain a + /// provider key. The inline object mirrors the AssetGenJob contract field set + /// (JobId, Kind, Provider, Action, State, Progress, AssetPath, AssetGuid, Error) — none of + /// which is a secret field. When AssetGenJob lands it replaces the inline object; the + /// assertions stay the same. + /// + [TestFixture] + public class AssetGenJobKeyLeakGuardTests + { + private string _dir; + + [SetUp] + public void SetUp() + { + _dir = Path.Combine(Path.GetTempPath(), "mcp_jobguard_" + System.Guid.NewGuid().ToString("N")); + SecureKeyStore.SetForTesting(new EncryptedFileKeyStore(_dir, "test-machine-id")); + } + + [TearDown] + public void TearDown() + { + SecureKeyStore.ResetForTesting(); + if (Directory.Exists(_dir)) Directory.Delete(_dir, true); + } + + [Test] + public void SerializedJob_ContainsNoKeyValue() + { + const string key = "tsk_this_must_never_serialize_123"; + SecureKeyStore.Current.Set("tripo", key); + + var job = new + { + JobId = System.Guid.NewGuid().ToString("N"), + Kind = "model", + Provider = "tripo", + Action = "generate", + State = "running", + Progress = 0.5f, + AssetPath = (string)null, + AssetGuid = (string)null, + Error = (string)null + }; + + string serialized = JsonConvert.SerializeObject(job); + StringAssert.DoesNotContain(key, serialized); + + // And even if an error path tried to embed the key, the redactor strips it. + string viaError = SecretRedactor.Scrub($"job {job.JobId} failed: provider rejected {key}"); + StringAssert.DoesNotContain(key, viaError); + StringAssert.Contains("***REDACTED***", viaError); + } + } + } + ``` + +- [ ] **Step 2: Run it — expect PASS immediately.** Test Runner ▸ EditMode ▸ `AssetGenJobKeyLeakGuardTests`. This is a guard, not red-green: it must be green on first run. If it ever goes red (someone adds a key-bearing field or an unredacted error path), the invariant has been broken. Expected: green. + +- [ ] **Step 3: Run the full AssetGen suite once.** Test Runner ▸ EditMode → run the whole `AssetGen` folder. Expected green: `EnvKeyOverrideTests`, `EncryptedFileKeyStoreTests`, `SecureKeyStoreFactoryTests`, `SecretRedactorTests`, `AssetGenJobKeyLeakGuardTests`. The three platform fixtures (`Mac*`/`Windows*`/`Linux*`) show as not-run (Explicit) unless run manually on the matching OS. Then `tools/check-unity-versions.sh` → green across the matrix. + +- [ ] **Step 4: Commit.** + ```bash + git add TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/AssetGenJobKeyLeakGuardTests.cs* + git commit -m "test(asset-gen): guard that serialized jobs never contain a key + +Locks in the invariant that the job-record shape has no secret field and that any accidental +embedding in an error string is redacted by SecretRedactor. + +Claude-Session: https://claude.ai/code/session_01Tjpb5gYgUe2AUJuRdXr7Lv" + ``` + +--- + +**Phase 1 exit criteria:** the EditMode `AssetGen` suite is green (deterministic fixtures), all `#if UNITY_EDITOR_*` branches compile across `tools/check-unity-versions.sh`, and the security guarantees hold: keys are encrypted/OS-stored at rest, an env override resolves env→store, no key is ever serialized into a job-shaped record, and every log/error path can be scrubbed via `SecretRedactor.Scrub`. Phase 2 (provider abstraction + Tripo) consumes `SecureKeyStore.Current.TryGet` at the moment of the HTTP call and `SecretRedactor.Scrub` on every error body. + +--- + +I have everything I need. Here is the Phase 8 plan. + +--- + +## Phase 8: Deps glTFast row + docs + version-compat sweep + final test pass + +This phase **wires together** everything built in Phases 0–7 and produces the "test ready" exit state. It has four jobs: (8.1) surface **glTFast** as an installable optional dependency in the existing Dependencies tab so GLB import works; (8.2) sweep the version-fragile Unity APIs the asset-gen code touches (`ModelImporter`, `UnityWebRequest`, UIToolkit) and add the one shim that is actually needed, then run the CI-matrix compile check; (8.3) document the feature in the README + a guide; (8.4) run the full Python test suite, inventory the C# EditMode tests authored across all phases, and hand the user a manual-verification checklist plus the definition-of-done exit checklist. + +No new tool, provider, or job code is added here — this is integration, docs, and verification. Everything lives in the worktree `/Users/scriptwonder/Documents/GitHub/unity-mcp/.worktrees/3d-asset-generation`. + +> **Ground truth confirmed by reading the repo:** +> - `BuildDependenciesSection` is at `MCPForUnityEditorWindow.cs:758`; the bulk array `upmPackages` is at line 780; helpers `IsUpmPackageInstalled` (line 1030, reads `Packages/manifest.json`), `InstallUpmPackage` (975), `RemoveUpmPackage` (980), and `AddDependencyRow` (878) are all private statics in `namespace MCPForUnity.Editor.Windows`. +> - The Install-All / Uninstall-All dialog strings (lines 786, 806) enumerate the packages and must be updated. +> - Python tool groups live in `Server/src/services/registry/tool_registry.py` (`TOOL_GROUPS` dict, line 18); the `asset_gen` key is added in **Phase 0**, not here. +> - The shim catalog source-of-truth is `MCPForUnity/Runtime/Helpers/UnityCompatShims.cs` (empty marker class, XML-doc catalog). +> - EditMode tests share a single assembly: `TestProjects/UnityMCPTests/Assets/Tests/EditMode/MCPForUnityTests.Editor.asmdef` (already references the editor assembly + UnityEditor). New AssetGen test files drop into `…/EditMode/AssetGen/` under that same asmdef — no new asmdef required. + +--- + +### Task 8.1: Add a glTFast optional-dependency row to the Dependencies tab + +**Files:** +- Modify: `MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs` (`BuildDependenciesSection`) +- Create (test): `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/DependenciesSectionGltfastTests.cs` + +**Interfaces:** +- Consumes (existing private statics): `IsUpmPackageInstalled(string packageId) -> bool`; `InstallUpmPackage(string packageId, Action onComplete)`; `RemoveUpmPackage(string packageId, Action onComplete)`; `AddDependencyRow(VisualElement parent, string name, string description, bool isInstalled, string installedText, string missingText, Action installAction, Action uninstallAction)`. +- Produces: a new dependency row whose detection is `IsUpmPackageInstalled("com.unity.cloud.gltfast") || Type.GetType("GLTFast.GltfImport, glTFast") != null`, and `"com.unity.cloud.gltfast"` added to the `upmPackages` bulk array. + +Steps: + +- [ ] **Step 1: Write the failing EditMode test.** Create `TestProjects/UnityMCPTests/Assets/Tests/EditMode/AssetGen/DependenciesSectionGltfastTests.cs`. It reflectively invokes the private `BuildDependenciesSection(VisualElement)` against a throwaway container and asserts a `glTFast` row and an `asset_gen` reference exist. (Reflection is used because the method is `private static`; invoking it only builds UI elements — no UPM calls happen until a button is clicked, so it is side-effect-free and CI-safe.) + +```csharp +using System.Linq; +using System.Reflection; +using NUnit.Framework; +using MCPForUnity.Editor.Windows; +using UnityEngine.UIElements; + +namespace MCPForUnity.Editor.Tests.AssetGen +{ + public class DependenciesSectionGltfastTests + { + private static VisualElement BuildSection() + { + MethodInfo method = typeof(MCPForUnityEditorWindow).GetMethod( + "BuildDependenciesSection", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.IsNotNull(method, "BuildDependenciesSection(VisualElement) must exist."); + + var container = new VisualElement(); + method.Invoke(null, new object[] { container }); + return container; + } + + [Test] + public void DependenciesSection_ContainsGltfastRow() + { + VisualElement container = BuildSection(); + bool found = container.Query